diff --git a/inc/Abilities/AgentMemoryAbilities.php b/inc/Abilities/AgentMemoryAbilities.php index 01231e86..be6644ca 100644 --- a/inc/Abilities/AgentMemoryAbilities.php +++ b/inc/Abilities/AgentMemoryAbilities.php @@ -3,10 +3,11 @@ * Agent Memory Abilities * * WordPress 6.9 Abilities API primitives for agent memory operations. - * Provides read/write access to MEMORY.md sections. + * Provides section-level read/write access to any agent file. * * @package DataMachine\Abilities * @since 0.30.0 + * @since 0.45.0 Added file parameter to all abilities for any-file support. */ namespace DataMachine\Abilities; @@ -39,7 +40,7 @@ private function registerAbilities(): void { 'datamachine/get-agent-memory', array( 'label' => 'Get Agent Memory', - 'description' => 'Read agent memory content — full file or a specific section', + 'description' => 'Read agent file content — full file or a specific section. Supports any agent file (MEMORY.md, SOUL.md, USER.md, etc.).', 'category' => 'datamachine', 'input_schema' => array( 'type' => 'object', @@ -53,6 +54,11 @@ private function registerAbilities(): void { 'description' => 'WordPress user ID for multi-agent scoping. Defaults to 0 (shared agent).', 'default' => 0, ), + 'file' => array( + 'type' => 'string', + 'description' => 'Target file (e.g. MEMORY.md, SOUL.md, USER.md). Defaults to MEMORY.md.', + 'default' => 'MEMORY.md', + ), 'section' => array( 'type' => 'string', 'description' => 'Section name to read (without ##). If omitted, returns the full file.', @@ -82,7 +88,7 @@ private function registerAbilities(): void { 'datamachine/update-agent-memory', array( 'label' => 'Update Agent Memory', - 'description' => 'Write to a specific section of agent memory — set (replace) or append', + 'description' => 'Write to a specific section of an agent file — set (replace) or append. Supports any agent file.', 'category' => 'datamachine', 'input_schema' => array( 'type' => 'object', @@ -96,6 +102,11 @@ private function registerAbilities(): void { 'description' => 'WordPress user ID for multi-agent scoping. Defaults to 0 (shared agent).', 'default' => 0, ), + 'file' => array( + 'type' => 'string', + 'description' => 'Target file (e.g. MEMORY.md, SOUL.md, USER.md). Defaults to MEMORY.md.', + 'default' => 'MEMORY.md', + ), 'section' => array( 'type' => 'string', 'description' => 'Section name (without ##). Created if it does not exist.', @@ -129,7 +140,7 @@ private function registerAbilities(): void { 'datamachine/search-agent-memory', array( 'label' => 'Search Agent Memory', - 'description' => 'Search across agent memory content. Returns matching lines with context, grouped by section.', + 'description' => 'Search across agent file content. Returns matching lines with context, grouped by section. Supports any agent file.', 'category' => 'datamachine', 'input_schema' => array( 'type' => 'object', @@ -144,6 +155,11 @@ private function registerAbilities(): void { 'description' => 'WordPress user ID for multi-agent scoping. Defaults to 0 (shared agent).', 'default' => 0, ), + 'file' => array( + 'type' => 'string', + 'description' => 'Target file to search (e.g. MEMORY.md, SOUL.md). Defaults to MEMORY.md.', + 'default' => 'MEMORY.md', + ), 'query' => array( 'type' => 'string', 'description' => 'Search term (case-insensitive substring match).', @@ -184,7 +200,7 @@ private function registerAbilities(): void { 'datamachine/list-agent-memory-sections', array( 'label' => 'List Agent Memory Sections', - 'description' => 'List all section headers in agent memory', + 'description' => 'List all section headers in an agent file. Supports any agent file.', 'category' => 'datamachine', 'input_schema' => array( 'type' => 'object', @@ -198,6 +214,11 @@ private function registerAbilities(): void { 'description' => 'WordPress user ID for multi-agent scoping. Defaults to 0 (shared agent).', 'default' => 0, ), + 'file' => array( + 'type' => 'string', + 'description' => 'Target file (e.g. MEMORY.md, SOUL.md, USER.md). Defaults to MEMORY.md.', + 'default' => 'MEMORY.md', + ), ), ), 'output_schema' => array( @@ -226,16 +247,14 @@ private function registerAbilities(): void { } /** - * Read agent memory — full file or a specific section. + * Read agent file — full file or a specific section. * * @param array $input Input parameters. * @return array Result. */ public static function getMemory( array $input ): array { - $user_id = (int) ( $input['user_id'] ?? 0 ); - $agent_id = (int) ( $input['agent_id'] ?? 0 ); - $memory = new AgentMemory( $user_id, $agent_id ); - $section = $input['section'] ?? null; + $memory = self::resolveMemory( $input ); + $section = $input['section'] ?? null; if ( null === $section || '' === $section ) { return $memory->get_all(); @@ -245,18 +264,28 @@ public static function getMemory( array $input ): array { } /** - * Update agent memory — set or append to a section. + * Update agent file — set or append to a section. * * @param array $input Input parameters. * @return array Result. */ public static function updateMemory( array $input ): array { - $user_id = (int) ( $input['user_id'] ?? 0 ); - $agent_id = (int) ( $input['agent_id'] ?? 0 ); - $memory = new AgentMemory( $user_id, $agent_id ); - $section = $input['section']; - $content = $input['content']; - $mode = $input['mode']; + $memory = self::resolveMemory( $input ); + $section = $input['section']; + $content = $input['content']; + $mode = $input['mode']; + + // Check editability for non-MEMORY.md files. + $filename = $input['file'] ?? 'MEMORY.md'; + if ( 'MEMORY.md' !== $filename ) { + $editable = \DataMachine\Engine\AI\MemoryFileRegistry::is_editable( $filename ); + if ( ! $editable ) { + return array( + 'success' => false, + 'message' => sprintf( 'File %s is read-only and cannot be edited via section write.', $filename ), + ); + } + } if ( 'append' === $mode ) { return $memory->append_to_section( $section, $content ); @@ -266,31 +295,42 @@ public static function updateMemory( array $input ): array { } /** - * Search agent memory content. + * Search agent file content. * * @param array $input Input parameters with 'query' and optional 'section'. * @return array Search results. */ public static function searchMemory( array $input ): array { - $user_id = (int) ( $input['user_id'] ?? 0 ); - $agent_id = (int) ( $input['agent_id'] ?? 0 ); - $memory = new AgentMemory( $user_id, $agent_id ); - $query = $input['query']; - $section = $input['section'] ?? null; + $memory = self::resolveMemory( $input ); + $query = $input['query']; + $section = $input['section'] ?? null; return $memory->search( $query, $section ); } /** - * List all section headers in agent memory. + * List all section headers in an agent file. * - * @param array $input Input parameters (unused). + * @param array $input Input parameters. * @return array Result. */ public static function listSections( array $input ): array { + $memory = self::resolveMemory( $input ); + return $memory->get_sections(); + } + + /** + * Resolve an AgentMemory instance from input parameters. + * + * @since 0.45.0 + * @param array $input Input parameters with optional user_id, agent_id, file. + * @return AgentMemory + */ + private static function resolveMemory( array $input ): AgentMemory { $user_id = (int) ( $input['user_id'] ?? 0 ); $agent_id = (int) ( $input['agent_id'] ?? 0 ); - $memory = new AgentMemory( $user_id, $agent_id ); - return $memory->get_sections(); + $filename = $input['file'] ?? 'MEMORY.md'; + + return new AgentMemory( $user_id, $agent_id, $filename ); } } diff --git a/inc/Cli/Commands/MemoryCommand.php b/inc/Cli/Commands/MemoryCommand.php index ba76f002..8a80cb2d 100644 --- a/inc/Cli/Commands/MemoryCommand.php +++ b/inc/Cli/Commands/MemoryCommand.php @@ -41,48 +41,64 @@ class MemoryCommand extends BaseCommand { private array $valid_modes = array( 'set', 'append' ); /** - * Read agent memory — full file or a specific section. + * Read an agent file — full file or a specific section. + * + * Supports any agent file (MEMORY.md, SOUL.md, USER.md, etc.). + * If the first argument ends in .md, it is treated as a filename. + * Otherwise it is treated as a section name within MEMORY.md. * * ## OPTIONS * + * [] + * : Filename (e.g. SOUL.md) or section name (without ##). + * Arguments ending in .md are treated as filenames. + * If omitted, reads full MEMORY.md. + * * [
] - * : Section name to read (without ##). If omitted, returns full file. + * : Section name when the first argument is a filename. * * [--agent=] - * : Agent slug or numeric ID. When provided, reads that agent's memory + * : Agent slug or numeric ID. When provided, reads that agent's file * instead of the current user's agent. * * ## EXAMPLES * - * # Read full memory file + * # Read full MEMORY.md (default) * wp datamachine agent read * - * # Read a specific section + * # Read a specific section from MEMORY.md * wp datamachine agent read "Fleet" * - * # Read lessons learned - * wp datamachine agent read "Lessons Learned" + * # Read full SOUL.md + * wp datamachine agent read SOUL.md * - * # Read memory for a specific agent - * wp datamachine agent read --agent=studio + * # Read a section from SOUL.md + * wp datamachine agent read SOUL.md "Identity" * - * # Read memory for a specific user + * # Read USER.md for a specific agent + * wp datamachine agent read USER.md --agent=studio + * + * # Read for a specific user * wp datamachine agent read --user=2 * * @subcommand read */ public function read( array $args, array $assoc_args ): void { - $section = $args[0] ?? null; - $input = $this->resolveMemoryScoping( $assoc_args ); + $parsed = $this->parseFileAndSection( $args ); + $input = $this->resolveMemoryScoping( $assoc_args ); + + if ( null !== $parsed['file'] ) { + $input['file'] = $parsed['file']; + } - if ( null !== $section ) { - $input['section'] = $section; + if ( null !== $parsed['section'] ) { + $input['section'] = $parsed['section']; } $result = AgentMemoryAbilities::getMemory( $input ); if ( ! $result['success'] ) { - $message = $result['message'] ?? 'Failed to read memory.'; + $message = $result['message'] ?? 'Failed to read file.'; if ( ! empty( $result['available_sections'] ) ) { $message .= "\nAvailable sections: " . implode( ', ', $result['available_sections'] ); } @@ -94,10 +110,13 @@ public function read( array $args, array $assoc_args ): void { } /** - * List all sections in agent memory. + * List all sections in an agent file. * * ## OPTIONS * + * [--file=] + * : Target file (e.g. SOUL.md, USER.md). Defaults to MEMORY.md. + * * [--agent=] * : Agent slug or numeric ID. * @@ -114,11 +133,14 @@ public function read( array $args, array $assoc_args ): void { * * ## EXAMPLES * - * # List memory sections + * # List MEMORY.md sections (default) * wp datamachine agent sections * - * # List as JSON - * wp datamachine agent sections --format=json + * # List SOUL.md sections + * wp datamachine agent sections --file=SOUL.md + * + * # List USER.md sections as JSON + * wp datamachine agent sections --file=USER.md --format=json * * # List sections for a specific agent * wp datamachine agent sections --agent=studio @@ -127,17 +149,24 @@ public function read( array $args, array $assoc_args ): void { */ public function sections( array $args, array $assoc_args ): void { $scoping = $this->resolveMemoryScoping( $assoc_args ); - $result = AgentMemoryAbilities::listSections( $scoping ); + $file = $assoc_args['file'] ?? null; + + if ( null !== $file ) { + $scoping['file'] = $file; + } + + $result = AgentMemoryAbilities::listSections( $scoping ); if ( ! $result['success'] ) { WP_CLI::error( $result['message'] ?? 'Failed to list sections.' ); return; } - $sections = $result['sections'] ?? array(); + $sections = $result['sections'] ?? array(); + $target_file = $result['file'] ?? $file ?? 'MEMORY.md'; if ( empty( $sections ) ) { - WP_CLI::log( 'No sections found in memory file.' ); + WP_CLI::log( sprintf( 'No sections found in %s.', $target_file ) ); return; } @@ -152,15 +181,26 @@ function ( $section ) { } /** - * Write to a section of agent memory. + * Write to a section of an agent file. + * + * Supports any agent file (MEMORY.md, SOUL.md, USER.md, etc.). + * If the first argument ends in .md, it is treated as a filename + * and the next two arguments are section and content. + * Otherwise the first two arguments are section and content + * targeting MEMORY.md. * * ## OPTIONS * - *
- * : Section name (without ##). Created if it does not exist. + * + * : Filename (e.g. SOUL.md) or section name (without ##). + * Arguments ending in .md are treated as filenames. + * + * + * : Section name when first arg is a filename, or content + * when first arg is a section name. * - * - * : Content to write. Use quotes for multi-word content. + * [] + * : Content to write when first arg is a filename. * * [--agent=] * : Agent slug or numeric ID. @@ -176,29 +216,32 @@ function ( $section ) { * * ## EXAMPLES * - * # Replace a section + * # Replace a section in MEMORY.md (default) * wp datamachine agent write "State" "- Data Machine v0.30.0 installed" * - * # Append to a section + * # Append to a section in MEMORY.md * wp datamachine agent write "Lessons Learned" "- Always check file permissions" --mode=append * - * # Create a new section - * wp datamachine agent write "New Section" "Initial content" + * # Write to a section in SOUL.md + * wp datamachine agent write SOUL.md "Identity" "I am chubes-bot" + * + * # Append to a section in USER.md + * wp datamachine agent write USER.md "Goals" "- Ship the feature" --mode=append * - * # Write to a specific agent's memory - * wp datamachine agent write "State" "- Studio agent active" --agent=studio + * # Write to a specific agent's file + * wp datamachine agent write SOUL.md "Voice" "Concise and direct" --agent=studio * * @subcommand write */ public function write( array $args, array $assoc_args ): void { - if ( empty( $args[0] ) || empty( $args[1] ) ) { - WP_CLI::error( 'Both section name and content are required.' ); + $parsed = $this->parseFileSectionContent( $args ); + + if ( null === $parsed ) { + WP_CLI::error( 'Usage: wp datamachine agent write []
[--mode=set|append]' ); return; } - $section = $args[0]; - $content = $args[1]; - $mode = $assoc_args['mode'] ?? 'set'; + $mode = $assoc_args['mode'] ?? 'set'; if ( ! in_array( $mode, $this->valid_modes, true ) ) { WP_CLI::error( sprintf( 'Invalid mode "%s". Must be one of: %s', $mode, implode( ', ', $this->valid_modes ) ) ); @@ -206,20 +249,23 @@ public function write( array $args, array $assoc_args ): void { } $scoping = $this->resolveMemoryScoping( $assoc_args ); - - $result = AgentMemoryAbilities::updateMemory( - array_merge( - $scoping, - array( - 'section' => $section, - 'content' => $content, - 'mode' => $mode, - ) + $input = array_merge( + $scoping, + array( + 'section' => $parsed['section'], + 'content' => $parsed['content'], + 'mode' => $mode, ) ); + if ( null !== $parsed['file'] ) { + $input['file'] = $parsed['file']; + } + + $result = AgentMemoryAbilities::updateMemory( $input ); + if ( ! $result['success'] ) { - WP_CLI::error( $result['message'] ?? 'Failed to write memory.' ); + WP_CLI::error( $result['message'] ?? 'Failed to write.' ); return; } @@ -227,13 +273,16 @@ public function write( array $args, array $assoc_args ): void { } /** - * Search agent memory content. + * Search agent file content. * * ## OPTIONS * * * : Search term (case-insensitive). * + * [--file=] + * : Target file to search (e.g. SOUL.md, USER.md). Defaults to MEMORY.md. + * * [--agent=] * : Agent slug or numeric ID. * @@ -242,14 +291,17 @@ public function write( array $args, array $assoc_args ): void { * * ## EXAMPLES * - * # Search all memory + * # Search MEMORY.md (default) * wp datamachine agent search "homeboy" * + * # Search SOUL.md + * wp datamachine agent search "identity" --file=SOUL.md + * * # Search within a section * wp datamachine agent search "docker" --section="Lessons Learned" * - * # Search a specific agent's memory - * wp datamachine agent search "socials" --agent=studio + * # Search a specific agent's file + * wp datamachine agent search "socials" --file=USER.md --agent=studio * * @subcommand search */ @@ -260,26 +312,33 @@ public function search( array $args, array $assoc_args ): void { } $query = $args[0]; + $file = $assoc_args['file'] ?? null; $section = $assoc_args['section'] ?? null; $scoping = $this->resolveMemoryScoping( $assoc_args ); - $result = AgentMemoryAbilities::searchMemory( - array_merge( - $scoping, - array( - 'query' => $query, - 'section' => $section, - ) + $input = array_merge( + $scoping, + array( + 'query' => $query, + 'section' => $section, ) ); + if ( null !== $file ) { + $input['file'] = $file; + } + + $result = AgentMemoryAbilities::searchMemory( $input ); + if ( ! $result['success'] ) { WP_CLI::error( $result['message'] ?? 'Search failed.' ); return; } + $target_file = $file ?? 'MEMORY.md'; + if ( empty( $result['matches'] ) ) { - WP_CLI::log( sprintf( 'No matches for "%s" in agent memory.', $query ) ); + WP_CLI::log( sprintf( 'No matches for "%s" in %s.', $query, $target_file ) ); return; } @@ -289,7 +348,7 @@ public function search( array $args, array $assoc_args ): void { WP_CLI::log( '' ); } - WP_CLI::success( sprintf( '%d match(es) found.', $result['match_count'] ) ); + WP_CLI::success( sprintf( '%d match(es) found in %s.', $result['match_count'], $target_file ) ); } /** @@ -557,26 +616,20 @@ private function daily_delete( DailyMemory $daily, string $date ): void { /** * Agent files operations. * - * Manage all agent memory files (SOUL.md, USER.md, MEMORY.md, etc.). - * Supports listing, reading, writing, and staleness detection. + * List and check agent memory files (SOUL.md, USER.md, MEMORY.md, etc.). + * For reading and writing file content, use `agent read` and `agent write` + * which support section-level operations on any file. * * ## OPTIONS * * - * : Action to perform: list, read, write, check. - * - * [] - * : Filename for read/write actions (e.g., SOUL.md, USER.md). + * : Action to perform: list, check. * * [--agent=] * : Agent slug or numeric ID. When provided, operates on that agent's * files instead of the current user's agent. Required for managing * shared agents in multi-agent setups. * - * [--content=] - * : Content to write (for write action). If omitted, reads from stdin. - * Use this flag when stdin is not available (e.g., studio wp). - * * [--days=] * : Staleness threshold in days for the check action. * --- @@ -602,21 +655,6 @@ private function daily_delete( DailyMemory $daily, string $date ): void { * # List files for a specific agent * wp datamachine agent files list --agent=studio * - * # Read an agent file - * wp datamachine agent files read SOUL.md - * - * # Read a specific agent's SOUL.md - * wp datamachine agent files read SOUL.md --agent=studio - * - * # Write to an agent file via stdin - * cat new-soul.md | wp datamachine agent files write SOUL.md - * - * # Write to a specific agent's file via stdin - * cat soul.md | wp datamachine agent files write SOUL.md --agent=studio - * - * # Write content directly (no stdin required) - * wp datamachine agent files write SOUL.md --content="# My Agent" - * * # Check for stale files (not updated in 7 days) * wp datamachine agent files check * @@ -630,7 +668,7 @@ private function daily_delete( DailyMemory $daily, string $date ): void { */ public function files( array $args, array $assoc_args ): void { if ( empty( $args ) ) { - WP_CLI::error( 'Usage: wp datamachine agent files [filename]' ); + WP_CLI::error( 'Usage: wp datamachine agent files ' ); return; } @@ -642,27 +680,11 @@ public function files( array $args, array $assoc_args ): void { case 'list': $this->files_list( $assoc_args, $user_id, $agent_id ); break; - case 'read': - $filename = $args[1] ?? null; - if ( ! $filename ) { - WP_CLI::error( 'Filename is required. Usage: wp datamachine agent files read ' ); - return; - } - $this->files_read( $filename, $user_id, $agent_id ); - break; - case 'write': - $filename = $args[1] ?? null; - if ( ! $filename ) { - WP_CLI::error( 'Filename is required. Usage: wp datamachine agent files write ' ); - return; - } - $this->files_write( $filename, $assoc_args, $user_id, $agent_id ); - break; case 'check': $this->files_check( $assoc_args, $user_id, $agent_id ); break; default: - WP_CLI::error( "Unknown files action: {$action}. Use: list, read, write, check" ); + WP_CLI::error( "Unknown files action: {$action}. Use: list, check" ); } } @@ -704,81 +726,6 @@ private function files_list( array $assoc_args, int $user_id = 0, ?int $agent_id $this->format_items( $items, array( 'file', 'size', 'modified', 'age' ), $assoc_args ); } - /** - * Read an agent file by name. - * - * @param string $filename File name (e.g., SOUL.md). - */ - private function files_read( string $filename, int $user_id = 0, ?int $agent_id = null ): void { - $agent_dir = $this->get_agent_dir( $user_id, $agent_id ); - $filepath = $agent_dir . '/' . $this->sanitize_agent_filename( $filename ); - - if ( ! file_exists( $filepath ) ) { - $available = $this->list_agent_filenames( $user_id, $agent_id ); - WP_CLI::error( sprintf( 'File "%s" not found. Available files: %s', $filename, implode( ', ', $available ) ) ); - return; - } - - // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents - WP_CLI::log( file_get_contents( $filepath ) ); - } - - /** - * Write to an agent file via --content flag or stdin. - * - * Delegates to the datamachine/write-agent-file ability for all - * validation, layer resolution, and file I/O. - * - * @param string $filename File name (e.g., SOUL.md). - * @param array $assoc_args Command arguments. - * @param int $user_id User ID. - * @param int|null $agent_id Agent ID. - */ - private function files_write( string $filename, array $assoc_args = array(), int $user_id = 0, ?int $agent_id = null ): void { - $content = $assoc_args['content'] ?? null; - - if ( null === $content ) { - $fs = FilesystemHelper::get(); - $content = $fs ? $fs->get_contents( 'php://stdin' ) : false; - - if ( false === $content || '' === trim( $content ) ) { - WP_CLI::error( 'No content provided. Use --content="..." or pipe via stdin: echo "content" | wp datamachine agent files write SOUL.md' ); - return; - } - } - - $ability = wp_get_ability( 'datamachine/write-agent-file' ); - - if ( ! $ability ) { - WP_CLI::error( "Ability 'datamachine/write-agent-file' not registered." ); - return; - } - - $input = array( - 'filename' => $filename, - 'content' => $content, - 'user_id' => $user_id, - ); - - if ( null !== $agent_id ) { - $input['agent_id'] = $agent_id; - } - - $result = $ability->execute( $input ); - - if ( is_wp_error( $result ) ) { - WP_CLI::error( $result->get_error_message() ); - return; - } - - if ( empty( $result['success'] ) ) { - WP_CLI::error( $result['error'] ?? 'Failed to write file.' ); - return; - } - - WP_CLI::success( sprintf( 'Wrote %s (layer: %s).', $result['filename'], $result['layer'] ) ); - } - /** * Check agent files for staleness. * @@ -836,6 +783,83 @@ private function files_check( array $assoc_args, int $user_id = 0, ?int $agent_i * * @return string */ + // ========================================================================= + // Argument parsing helpers + // ========================================================================= + + /** + * Check if a string looks like a filename (ends in .md). + * + * @since 0.45.0 + * @param string $arg Argument to check. + * @return bool + */ + private function isFilename( string $arg ): bool { + return (bool) preg_match( '/\.md$/i', $arg ); + } + + /** + * Parse positional args into file and section for read commands. + * + * Disambiguation rules: + * - No args: file=null, section=null (full MEMORY.md) + * - One arg ending in .md: file=arg, section=null (full file) + * - One arg not .md: file=null, section=arg (MEMORY.md section) + * - Two args: file=first, section=second + * + * @since 0.45.0 + * @param array $args Positional arguments. + * @return array{file: ?string, section: ?string} + */ + private function parseFileAndSection( array $args ): array { + if ( empty( $args ) ) { + return array( 'file' => null, 'section' => null ); + } + + if ( count( $args ) >= 2 ) { + return array( 'file' => $args[0], 'section' => $args[1] ); + } + + // Single argument — disambiguate. + if ( $this->isFilename( $args[0] ) ) { + return array( 'file' => $args[0], 'section' => null ); + } + + return array( 'file' => null, 'section' => $args[0] ); + } + + /** + * Parse positional args into file, section, and content for write commands. + * + * Disambiguation rules: + * - Two args, first not .md: section=first, content=second (MEMORY.md) + * - Three args, first is .md: file=first, section=second, content=third + * - Two args, first is .md: error (missing content) + * + * @since 0.45.0 + * @param array $args Positional arguments. + * @return array{file: ?string, section: string, content: string}|null Null on invalid args. + */ + private function parseFileSectionContent( array $args ): ?array { + if ( count( $args ) >= 3 && $this->isFilename( $args[0] ) ) { + return array( + 'file' => $args[0], + 'section' => $args[1], + 'content' => $args[2], + ); + } + + if ( count( $args ) >= 2 && ! $this->isFilename( $args[0] ) ) { + return array( + 'file' => null, + 'section' => $args[0], + 'content' => $args[1], + ); + } + + return null; + } + /** * Resolve memory scoping from CLI flags. * @@ -866,27 +890,6 @@ private function get_agent_dir( int $user_id = 0, ?int $agent_id = null ): strin return $directory_manager->get_agent_identity_directory_for_user( $user_id ); } - /** - * Sanitize an agent filename (allow only alphanumeric, hyphens, underscores, dots). - * - * @param string $filename Raw filename. - * @return string Sanitized filename. - */ - private function sanitize_agent_filename( string $filename ): string { - return preg_replace( '/[^a-zA-Z0-9._-]/', '', basename( $filename ) ); - } - - /** - * List available agent filenames. - * - * @return string[] - */ - private function list_agent_filenames( int $user_id = 0, ?int $agent_id = null ): array { - $agent_dir = $this->get_agent_dir( $user_id, $agent_id ); - $files = glob( $agent_dir . '/*.md' ); - return array_map( 'basename', $files ? $files : array() ); - } - // ========================================================================= // Composable files // ========================================================================= diff --git a/inc/Core/FilesRepository/AgentMemory.php b/inc/Core/FilesRepository/AgentMemory.php index 18725951..d10f08bc 100644 --- a/inc/Core/FilesRepository/AgentMemory.php +++ b/inc/Core/FilesRepository/AgentMemory.php @@ -2,11 +2,13 @@ /** * Agent Memory Service * - * Provides structured read/write operations for agent memory files (MEMORY.md). - * Parses markdown sections and supports section-level operations. + * Provides structured read/write operations for agent memory files. + * Parses markdown sections and supports section-level operations + * on any agent file (MEMORY.md, SOUL.md, USER.md, etc.). * * @package DataMachine\Core\FilesRepository * @since 0.30.0 + * @since 0.45.0 Generalized to support any agent file via $filename parameter. */ namespace DataMachine\Core\FilesRepository; @@ -43,28 +45,71 @@ class AgentMemory { */ private int $user_id; + /** + * Target filename (e.g. MEMORY.md, SOUL.md, USER.md). + * + * @since 0.45.0 + * @var string + */ + private string $filename; + /** * @since 0.37.0 Added $user_id parameter for multi-agent partitioning. * @since 0.41.0 Added $agent_id parameter for agent-first resolution. + * @since 0.45.0 Added $filename parameter for any-file support. * - * @param int $user_id WordPress user ID. 0 = legacy shared directory. - * @param int $agent_id Agent ID for direct resolution. 0 = resolve from user_id. + * @param int $user_id WordPress user ID. 0 = legacy shared directory. + * @param int $agent_id Agent ID for direct resolution. 0 = resolve from user_id. + * @param string $filename Target filename. Defaults to MEMORY.md for backwards compatibility. */ - public function __construct( int $user_id = 0, int $agent_id = 0 ) { + public function __construct( int $user_id = 0, int $agent_id = 0, string $filename = 'MEMORY.md' ) { $this->directory_manager = new DirectoryManager(); $this->user_id = $this->directory_manager->get_effective_user_id( $user_id ); - $agent_dir = $this->directory_manager->resolve_agent_directory( array( - 'agent_id' => $agent_id, - 'user_id' => $this->user_id, - ) ); - $this->file_path = "{$agent_dir}/MEMORY.md"; + $this->filename = $this->sanitize_filename( $filename ); + $this->file_path = $this->resolve_file_path( $agent_id ); // Self-heal: ensure agent files exist on first use. DirectoryManager::ensure_agent_files(); } /** - * Get the full path to MEMORY.md. + * Resolve the file path using MemoryFileRegistry layer awareness. + * + * For registered files, uses the canonical layer (shared, agent, user, network). + * For unregistered files, defaults to the agent directory. + * + * @since 0.45.0 + * @param int $agent_id Agent ID for directory resolution. + * @return string Absolute file path. + */ + private function resolve_file_path( int $agent_id ): string { + $registry_layer = \DataMachine\Engine\AI\MemoryFileRegistry::get_layer( $this->filename ); + $dm = $this->directory_manager; + + if ( null !== $registry_layer ) { + switch ( $registry_layer ) { + case 'shared': + return $dm->get_shared_directory() . '/' . $this->filename; + case 'user': + return $dm->get_user_directory( $this->user_id ) . '/' . $this->filename; + case 'network': + return $dm->get_network_directory() . '/' . $this->filename; + case 'agent': + default: + break; // Fall through to agent directory below. + } + } + + // Default: agent directory. + $agent_dir = $dm->resolve_agent_directory( array( + 'agent_id' => $agent_id, + 'user_id' => $this->user_id, + ) ); + return "{$agent_dir}/{$this->filename}"; + } + + /** + * Get the full path to the target file. * * @return string */ @@ -73,16 +118,26 @@ public function get_file_path(): string { } /** - * Read the full memory file content. + * Get the target filename. + * + * @since 0.45.0 + * @return string + */ + public function get_filename(): string { + return $this->filename; + } + + /** + * Read the full file content. * - * @return array{success: bool, content?: string, message?: string} + * @return array{success: bool, content?: string, file?: string, message?: string} */ public function get_all(): array { $fs = FilesystemHelper::get(); if ( ! file_exists( $this->file_path ) ) { return array( 'success' => false, - 'message' => 'Memory file does not exist.', + 'message' => sprintf( 'File %s does not exist.', $this->filename ), ); } @@ -90,23 +145,24 @@ public function get_all(): array { return array( 'success' => true, + 'file' => $this->filename, 'content' => $content, ); } /** - * List all section headers in the memory file. + * List all section headers in the file. * * Sections are defined by markdown ## headers. * - * @return array{success: bool, sections?: string[], message?: string} + * @return array{success: bool, sections?: string[], file?: string, message?: string} */ public function get_sections(): array { $fs = FilesystemHelper::get(); if ( ! file_exists( $this->file_path ) ) { return array( 'success' => false, - 'message' => 'Memory file does not exist.', + 'message' => sprintf( 'File %s does not exist.', $this->filename ), ); } @@ -115,6 +171,7 @@ public function get_sections(): array { return array( 'success' => true, + 'file' => $this->filename, 'sections' => $sections, ); } @@ -130,7 +187,7 @@ public function get_section( string $section_name ): array { if ( ! file_exists( $this->file_path ) ) { return array( 'success' => false, - 'message' => 'Memory file does not exist.', + 'message' => sprintf( 'File %s does not exist.', $this->filename ), ); } @@ -269,7 +326,7 @@ public function search( string $query, ?string $section = null, int $context_lin if ( ! file_exists( $this->file_path ) ) { return array( 'success' => false, - 'message' => 'Memory file does not exist.', + 'message' => sprintf( 'File %s does not exist.', $this->filename ), 'matches' => array(), 'match_count' => 0, ); @@ -394,25 +451,47 @@ private function replace_section_content( string $file_content, array $position, } /** - * Ensure the memory file and directory exist. + * Ensure the target file and directory exist. * * Uses scaffold defaults when available instead of a bare stub, - * so a recreated MEMORY.md includes the standard sections. + * so a recreated file includes the standard sections. */ private function ensure_file_exists(): void { if ( ! file_exists( $this->file_path ) ) { $ability = \DataMachine\Abilities\File\ScaffoldAbilities::get_ability(); if ( $ability ) { $ability->execute( array( - 'filename' => 'MEMORY.md', + 'filename' => $this->filename, 'user_id' => $this->user_id, ) ); } + + // If scaffold didn't create it (no template for this file), create empty. + if ( ! file_exists( $this->file_path ) ) { + $dir = dirname( $this->file_path ); + $dm = new DirectoryManager(); + $dm->ensure_directory_exists( $dir ); + + $fs = FilesystemHelper::get(); + $fs->put_contents( $this->file_path, "# {$this->filename}\n" ); + FilesystemHelper::make_group_writable( $this->file_path ); + } } } /** - * Write content to the memory file. + * Sanitize a filename to prevent directory traversal. + * + * @since 0.45.0 + * @param string $filename Raw filename. + * @return string Sanitized filename. + */ + private function sanitize_filename( string $filename ): string { + return preg_replace( '/[^a-zA-Z0-9._-]/', '', basename( $filename ) ); + } + + /** + * Write content to the target file. * * Logs a warning if the resulting file exceeds MAX_FILE_SIZE. * diff --git a/inc/Engine/AI/Tools/Global/AgentMemory.php b/inc/Engine/AI/Tools/Global/AgentMemory.php index 708a744e..43312d34 100644 --- a/inc/Engine/AI/Tools/Global/AgentMemory.php +++ b/inc/Engine/AI/Tools/Global/AgentMemory.php @@ -3,11 +3,12 @@ * Agent Memory AI Tool - Persistent memory management for AI agents * * Delegates to AgentMemoryAbilities for section-level read/write operations - * on the agent's MEMORY.md file. Provides persistent knowledge storage + * on any agent file. Provides persistent knowledge storage * across sessions for all agent types. * * @package DataMachine\Engine\AI\Tools\Global * @since 0.30.0 + * @since 0.45.0 Added file parameter for any-file support. */ namespace DataMachine\Engine\AI\Tools\Global; @@ -45,7 +46,7 @@ public function handle_tool_call( array $parameters, array $tool_def = array() ) } /** - * Read full memory or a specific section. + * Read full file or a specific section. * * @param array $parameters Tool parameters. * @return array Response. @@ -61,13 +62,17 @@ private function handleGet( array $parameters ): array { ); } - $result = $ability->execute( - array( - 'user_id' => $user_id, - 'section' => $parameters['section'] ?? '', - ) + $input = array( + 'user_id' => $user_id, + 'section' => $parameters['section'] ?? '', ); + if ( ! empty( $parameters['file'] ) ) { + $input['file'] = $parameters['file']; + } + + $result = $ability->execute( $input ); + if ( is_wp_error( $result ) ) { return $this->buildErrorResponse( $result->get_error_message(), 'agent_memory' ); } @@ -121,22 +126,26 @@ private function handleUpdate( array $parameters ): array { ); } - $result = $ability->execute( - array( - 'user_id' => $user_id, - 'section' => $section, - 'content' => $content, - 'mode' => $mode, - ) + $input = array( + 'user_id' => $user_id, + 'section' => $section, + 'content' => $content, + 'mode' => $mode, ); + if ( ! empty( $parameters['file'] ) ) { + $input['file'] = $parameters['file']; + } + + $result = $ability->execute( $input ); + if ( is_wp_error( $result ) ) { return $this->buildErrorResponse( $result->get_error_message(), 'agent_memory' ); } if ( ! $this->isAbilitySuccess( $result ) ) { return $this->buildErrorResponse( - $this->getAbilityError( $result, 'Failed to update agent memory.' ), + $this->getAbilityError( $result, 'Failed to update agent file.' ), 'agent_memory' ); } @@ -164,7 +173,13 @@ private function handleListSections( array $parameters = array() ): array { ); } - $result = $ability->execute( array( 'user_id' => $user_id ) ); + $input = array( 'user_id' => $user_id ); + + if ( ! empty( $parameters['file'] ) ) { + $input['file'] = $parameters['file']; + } + + $result = $ability->execute( $input ); if ( is_wp_error( $result ) ) { return $this->buildErrorResponse( $result->get_error_message(), 'agent_memory' ); @@ -193,7 +208,7 @@ public function getToolDefinition(): array { return array( 'class' => __CLASS__, 'method' => 'handle_tool_call', - 'description' => 'Manage persistent agent memory (MEMORY.md) — long-lived knowledge that survives across sessions. Stored as markdown sections (## headers). Use "list_sections" to see what exists, "get" to read content, and "update" to write. Use "append" mode to add new information without losing existing content. Use "set" mode to replace a section entirely. For session activity and temporal events, use agent_daily_memory instead.', + 'description' => 'Manage persistent agent files with section-level operations. Works on any agent file: MEMORY.md (default), SOUL.md, USER.md, etc. Stored as markdown with ## section headers. Use "list_sections" to see what exists, "get" to read content, and "update" to write. Use "append" mode to add new information without losing existing content. Use "set" mode to replace a section entirely. For session activity and temporal events, use agent_daily_memory instead.', 'requires_config' => false, 'parameters' => array( 'user_id' => array( @@ -204,12 +219,17 @@ public function getToolDefinition(): array { 'action' => array( 'type' => 'string', 'required' => true, - 'description' => 'Action to perform: "get" (read memory), "update" (write to section), or "list_sections" (show all section headers).', + 'description' => 'Action to perform: "get" (read file/section), "update" (write to section), or "list_sections" (show all section headers).', + ), + 'file' => array( + 'type' => 'string', + 'required' => false, + 'description' => 'Target file. Defaults to MEMORY.md. Use SOUL.md, USER.md, SITE.md, etc. for other agent files.', ), 'section' => array( 'type' => 'string', 'required' => false, - 'description' => 'Section name without "##" prefix. Required for "update". Optional for "get" (omit to read full memory).', + 'description' => 'Section name without "##" prefix. Required for "update". Optional for "get" (omit to read full file).', ), 'content' => array( 'type' => 'string',