diff --git a/docs/core-system/wordpress-as-agent-memory.md b/docs/core-system/wordpress-as-agent-memory.md index f9d8b7eb..b6114777 100644 --- a/docs/core-system/wordpress-as-agent-memory.md +++ b/docs/core-system/wordpress-as-agent-memory.md @@ -46,7 +46,7 @@ Data Machine ships with core memory files across layers: **USER.md** — Information about the human. Timezone, preferences, communication style, background. Lives in the user layer. -The **CoreMemoryFilesDirective** loads core files directly from the three layer directories (shared → agent → user), then loads any custom files registered via the **MemoryFileRegistry**. Core files are not loaded from the registry — the registry is for extensions only. +The **CoreMemoryFilesDirective** loads all files from the **MemoryFileRegistry**, resolving each to its layer directory. Core and custom files use the same registration API — the `datamachine_memory_files` action hook provides the extension point for third parties. **Additional files** serve workflow-specific purposes — editorial strategies, project briefs, content plans. Each pipeline can select which additional files it needs, so a social media workflow doesn't carry the weight of a full content strategy. These are injected at Priority 40 (per-pipeline) or Priority 45 (per-flow). @@ -311,23 +311,24 @@ Data Machine uses a **directive system** — a priority-ordered chain that injec ### Core Memory Files (Priority 20) -The **CoreMemoryFilesDirective** loads files in two stages: +The **CoreMemoryFilesDirective** loads all files from the **MemoryFileRegistry**, resolving each to its layer directory: -**Stage 1 — Layer directories:** Loads core files directly from the three-layer directory structure: ``` -shared/SITE.md → shared/RULES.md → agents/{slug}/SOUL.md → agents/{slug}/MEMORY.md → users/{id}/USER.md +Registry priority order → resolve layer → read file → inject as system message ``` -**Stage 2 — Custom registry:** Loads any additional files registered in the **MemoryFileRegistry** (excluding SOUL.md, USER.md, MEMORY.md which are already loaded from layers): +All core files register through the same API that plugins use: ```php -// Default registrations in bootstrap.php (used for registry tracking, not loading) -MemoryFileRegistry::register( 'SOUL.md', 10 ); -MemoryFileRegistry::register( 'USER.md', 20 ); -MemoryFileRegistry::register( 'MEMORY.md', 30 ); +// From bootstrap.php — these are just the defaults. +MemoryFileRegistry::register( 'SITE.md', 10, [ 'layer' => 'shared', 'protected' => true ] ); +MemoryFileRegistry::register( 'RULES.md', 15, [ 'layer' => 'shared', 'protected' => true ] ); +MemoryFileRegistry::register( 'SOUL.md', 20, [ 'layer' => 'agent', 'protected' => true ] ); +MemoryFileRegistry::register( 'USER.md', 25, [ 'layer' => 'user', 'protected' => true ] ); +MemoryFileRegistry::register( 'MEMORY.md', 30, [ 'layer' => 'agent', 'protected' => true ] ); ``` -The priority number within the registry determines **load order** for custom files. Missing files are silently skipped. Empty files are silently skipped. +The priority number determines **load order**. Missing files are silently skipped. Empty files are silently skipped. Third parties register additional files through the same `register()` API or the `datamachine_memory_files` action hook. Plugins and themes can register their own memory files through the same API: @@ -548,9 +549,9 @@ The shared/agent/user layer separation serves distinct purposes: This means a single WordPress site can host multiple agents with distinct identities while sharing common site context. -### Layer directories over registry for core files +### Registry-driven loading with layer resolution -Core memory files are loaded directly from their layer directories by `CoreMemoryFilesDirective`: SITE.md and RULES.md from shared, SOUL.md and MEMORY.md from agent, USER.md from user. The `MemoryFileRegistry` is used only for custom extensions. This keeps the core loading path simple, predictable, and each file in exactly one layer. +All memory files — core and custom — register through the same `MemoryFileRegistry` API. Each registration specifies its layer (`shared`, `agent`, `user`), and the `CoreMemoryFilesDirective` resolves each file to the correct directory at runtime. This makes the system fully extensible: plugins register files in any layer through the same API that core uses. No special-casing, no hardcoded file lists. ### Selective injection over RAG @@ -654,16 +655,65 @@ wp datamachine workspace git status --repo= --allow-root ### Register Custom Memory Files -Add files to the core injection (Priority 20) via the registry: +Add files to the core injection (Priority 20) via the registry. Each file specifies its **layer**, which determines where it lives and who can see it: ```php use DataMachine\Engine\AI\MemoryFileRegistry; -// Register a file to be injected into all AI calls -MemoryFileRegistry::register( 'brand-guidelines.md', 40 ); +// Agent-layer file — scoped to a single agent. +MemoryFileRegistry::register( 'brand-guidelines.md', 40, [ + 'layer' => MemoryFileRegistry::LAYER_AGENT, + 'label' => 'Brand Guidelines', + 'description' => 'Voice, tone, and visual brand standards.', +] ); + +// Shared-layer file — visible to ALL agents on the site. +MemoryFileRegistry::register( 'editorial-policy.md', 45, [ + 'layer' => MemoryFileRegistry::LAYER_SHARED, + 'label' => 'Editorial Policy', + 'description' => 'Site-wide editorial standards.', +] ); + +// User-layer file — visible to ALL agents for a specific user. +MemoryFileRegistry::register( 'work-context.md', 50, [ + 'layer' => MemoryFileRegistry::LAYER_USER, + 'label' => 'Work Context', + 'description' => 'User-specific project context.', +] ); + +// Protected file — cannot be deleted. +MemoryFileRegistry::register( 'compliance.md', 12, [ + 'layer' => MemoryFileRegistry::LAYER_SHARED, + 'protected' => true, + 'label' => 'Compliance Rules', +] ); ``` -The file must exist in the agent's directory (`wp-content/uploads/datamachine-files/agents/{agent_slug}/`). Missing files are silently skipped. +**Registration arguments:** + +| Argument | Type | Default | Description | +|----------|------|---------|-------------| +| `layer` | string | `'agent'` | One of `shared`, `agent`, `user` | +| `protected` | bool | `false` | Protected files cannot be deleted or blanked | +| `label` | string | *derived from filename* | Human-readable display label | +| `description` | string | `''` | Purpose description shown in the admin UI | + +Files are resolved to their layer directory at runtime. Missing files are silently skipped. + +### Extension Hook + +Third parties can register files via the `datamachine_memory_files` action, which fires once per request when the registry is first consumed: + +```php +add_action( 'datamachine_memory_files', function( $current_files ) { + // Inspect existing registrations if needed. + // Register additional files via the standard API. + MemoryFileRegistry::register( 'my-plugin-context.md', 60, [ + 'layer' => MemoryFileRegistry::LAYER_AGENT, + 'label' => 'My Plugin Context', + ] ); +} ); +``` ### Custom Directives diff --git a/inc/Abilities/File/AgentFileAbilities.php b/inc/Abilities/File/AgentFileAbilities.php index 309f88ba..01b2da6f 100644 --- a/inc/Abilities/File/AgentFileAbilities.php +++ b/inc/Abilities/File/AgentFileAbilities.php @@ -3,11 +3,15 @@ * Agent File Abilities * * Abilities API primitives for agent memory file operations. - * Handles the agent identity layer (SOUL.md, MEMORY.md, custom files) - * and composes with the user layer (USER.md) for a unified view. + * Supports all three layers (shared, agent, user) with routing + * driven by the MemoryFileRegistry for registered files. + * + * New files default to the agent layer. A `layer` parameter can + * explicitly target shared or user layers. * * @package DataMachine\Abilities\File * @since 0.38.0 + * @since 0.42.0 Layer-aware CRUD via MemoryFileRegistry. */ namespace DataMachine\Abilities\File; @@ -16,6 +20,7 @@ use DataMachine\Core\FilesRepository\DailyMemory; use DataMachine\Core\FilesRepository\DirectoryManager; use DataMachine\Core\FilesRepository\FilesystemHelper; +use DataMachine\Engine\AI\MemoryFileRegistry; defined( 'ABSPATH' ) || exit; @@ -61,7 +66,7 @@ private function registerListAgentFiles(): void { 'datamachine/list-agent-files', array( 'label' => __( 'List Agent Files', 'data-machine' ), - 'description' => __( 'List memory files from the agent identity and user layers.', 'data-machine' ), + 'description' => __( 'List memory files from all layers (shared, agent, user).', 'data-machine' ), 'category' => 'datamachine', 'input_schema' => array( 'type' => 'object', @@ -130,7 +135,7 @@ private function registerWriteAgentFile(): void { 'datamachine/write-agent-file', array( 'label' => __( 'Write Agent File', 'data-machine' ), - 'description' => __( 'Write or update content for an agent memory file. Protected files cannot be blanked.', 'data-machine' ), + 'description' => __( 'Write or update content for a memory file. Layer is resolved from the registry, or can be explicitly specified.', 'data-machine' ), 'category' => 'datamachine', 'input_schema' => array( 'type' => 'object', @@ -138,12 +143,17 @@ private function registerWriteAgentFile(): void { 'properties' => array( 'filename' => array( 'type' => 'string', - 'description' => __( 'Name of the agent file to write', 'data-machine' ), + 'description' => __( 'Name of the memory file to write', 'data-machine' ), ), 'content' => array( 'type' => 'string', 'description' => __( 'Content to write to the file', 'data-machine' ), ), + 'layer' => array( + 'type' => 'string', + 'description' => __( 'Target layer: shared, agent, or user. For registered files, defaults to the registered layer. For new files, defaults to agent.', 'data-machine' ), + 'enum' => array( 'shared', 'agent', 'user' ), + ), 'user_id' => array( 'type' => 'integer', 'description' => __( 'WordPress user ID for multi-agent scoping.', 'data-machine' ), @@ -156,6 +166,7 @@ private function registerWriteAgentFile(): void { 'properties' => array( 'success' => array( 'type' => 'boolean' ), 'filename' => array( 'type' => 'string' ), + 'layer' => array( 'type' => 'string' ), 'error' => array( 'type' => 'string' ), ), ), @@ -171,7 +182,7 @@ private function registerDeleteAgentFile(): void { 'datamachine/delete-agent-file', array( 'label' => __( 'Delete Agent File', 'data-machine' ), - 'description' => __( 'Delete an agent memory file. Protected files (SOUL.md, MEMORY.md) cannot be deleted.', 'data-machine' ), + 'description' => __( 'Delete a memory file. Protected files cannot be deleted.', 'data-machine' ), 'category' => 'datamachine', 'input_schema' => array( 'type' => 'object', @@ -208,7 +219,7 @@ private function registerUploadAgentFile(): void { 'datamachine/upload-agent-file', array( 'label' => __( 'Upload Agent File', 'data-machine' ), - 'description' => __( 'Upload a file to the agent memory directory.', 'data-machine' ), + 'description' => __( 'Upload a file to a memory layer directory.', 'data-machine' ), 'category' => 'datamachine', 'input_schema' => array( 'type' => 'object', @@ -225,6 +236,11 @@ private function registerUploadAgentFile(): void { 'size' => array( 'type' => 'integer' ), ), ), + 'layer' => array( + 'type' => 'string', + 'description' => __( 'Target layer for the uploaded file. Default agent.', 'data-machine' ), + 'enum' => array( 'shared', 'agent', 'user' ), + ), 'user_id' => array( 'type' => 'integer', 'description' => __( 'WordPress user ID for multi-agent scoping.', 'data-machine' ), @@ -263,7 +279,7 @@ public function checkPermission(): bool { // ========================================================================= /** - * List agent memory files from both identity and user layers. + * List agent memory files from all layers. * * @param array $input Input parameters. * @return array Result with files list. @@ -309,12 +325,17 @@ public function executeListAgentFiles( array $input ): array { $filepath = "{$dir}/{$entry}"; if ( is_file( $filepath ) ) { + $registry_meta = MemoryFileRegistry::get( $entry ); $files[] = array( - 'filename' => $entry, - 'size' => filesize( $filepath ), - 'modified' => gmdate( 'c', filemtime( $filepath ) ), - 'type' => 'core', - 'layer' => $layer, + 'filename' => $entry, + 'size' => filesize( $filepath ), + 'modified' => gmdate( 'c', filemtime( $filepath ) ), + 'type' => 'core', + 'layer' => $registry_meta ? $registry_meta['layer'] : $layer, + 'protected' => MemoryFileRegistry::is_protected( $entry ), + 'registered' => null !== $registry_meta, + 'label' => $registry_meta['label'] ?? self::filename_to_label( $entry ), + 'description' => $registry_meta['description'] ?? '', ); $seen[ $entry ] = true; } @@ -365,7 +386,7 @@ public function executeGetAgentFile( array $input ): array { if ( ! $filepath ) { return array( 'success' => false, - 'error' => sprintf( 'File %s not found in agent directory', $filename ), + 'error' => sprintf( 'File %s not found in any layer', $filename ), ); } @@ -383,9 +404,12 @@ public function executeGetAgentFile( array $input ): array { } /** - * Write content to an agent file. + * Write content to a memory file. * - * Routes to the correct layer: USER.md → user dir, everything else → agent identity dir. + * Layer resolution order: + * 1. Explicit `layer` parameter (if provided) + * 2. Registry layer (if file is registered) + * 3. Default: agent layer * * @param array $input Input parameters. * @return array Result with write status. @@ -394,26 +418,27 @@ public function executeWriteAgentFile( array $input ): array { $filename = sanitize_file_name( $input['filename'] ?? '' ); $content = $input['content'] ?? ''; - if ( in_array( $filename, FileConstants::PROTECTED_FILES, true ) && '' === trim( $content ) ) { + if ( MemoryFileRegistry::is_protected( $filename ) && '' === trim( $content ) ) { return array( 'success' => false, 'error' => sprintf( 'Cannot write empty content to protected file: %s', $filename ), ); } - $dm = new DirectoryManager(); - $user_id = $dm->get_effective_user_id( (int) ( $input['user_id'] ?? 0 ) ); - $target_dir = in_array( $filename, FileConstants::USER_LAYER_FILES, true ) - ? $dm->get_user_directory( $user_id ) - : $dm->resolve_agent_directory( array( - 'agent_id' => (int) ( $input['agent_id'] ?? 0 ), - 'user_id' => $user_id, - ) ); + // Resolve target layer. + $explicit_layer = $input['layer'] ?? null; + $registry_layer = MemoryFileRegistry::get_layer( $filename ); + $target_layer = $explicit_layer ?? $registry_layer ?? MemoryFileRegistry::LAYER_AGENT; + + $dm = new DirectoryManager(); + $user_id = $dm->get_effective_user_id( (int) ( $input['user_id'] ?? 0 ) ); + + $target_dir = $this->resolveLayerDirectory( $dm, $target_layer, $user_id, (int) ( $input['agent_id'] ?? 0 ) ); if ( ! $dm->ensure_directory_exists( $target_dir ) ) { return array( 'success' => false, - 'error' => 'Failed to create agent directory', + 'error' => 'Failed to create target directory', ); } @@ -442,17 +467,21 @@ public function executeWriteAgentFile( array $input ): array { 'datamachine_log', 'info', 'Agent file written via ability', - array( 'filename' => $filename ) + array( + 'filename' => $filename, + 'layer' => $target_layer, + ) ); return array( 'success' => true, 'filename' => $filename, + 'layer' => $target_layer, ); } /** - * Delete an agent file. + * Delete a memory file. * * @param array $input Input parameters. * @return array Result with deletion status. @@ -460,7 +489,7 @@ public function executeWriteAgentFile( array $input ): array { public function executeDeleteAgentFile( array $input ): array { $filename = sanitize_file_name( $input['filename'] ?? '' ); - if ( in_array( $filename, FileConstants::PROTECTED_FILES, true ) ) { + if ( MemoryFileRegistry::is_protected( $filename ) ) { return array( 'success' => false, 'error' => sprintf( 'Cannot delete protected file: %s', $filename ), @@ -475,7 +504,7 @@ public function executeDeleteAgentFile( array $input ): array { if ( ! $filepath ) { return array( 'success' => false, - 'error' => sprintf( 'File %s not found in agent directory', $filename ), + 'error' => sprintf( 'File %s not found in any layer', $filename ), ); } @@ -494,12 +523,12 @@ public function executeDeleteAgentFile( array $input ): array { return array( 'success' => true, - 'message' => sprintf( 'File %s deleted from agent directory', $filename ), + 'message' => sprintf( 'File %s deleted', $filename ), ); } /** - * Upload a file to the agent memory directory. + * Upload a file to a memory layer directory. * * @param array $input Input parameters. * @return array Result with updated file list. @@ -514,28 +543,26 @@ public function executeUploadAgentFile( array $input ): array { ); } - $dm = new DirectoryManager(); - $user_id = $dm->get_effective_user_id( (int) ( $input['user_id'] ?? 0 ) ); - $agent_id = (int) ( $input['agent_id'] ?? 0 ); - $agent_dir = $dm->resolve_agent_directory( array( - 'agent_id' => $agent_id, - 'user_id' => $user_id, - ) ); + $dm = new DirectoryManager(); + $user_id = $dm->get_effective_user_id( (int) ( $input['user_id'] ?? 0 ) ); + $agent_id = (int) ( $input['agent_id'] ?? 0 ); + $target_layer = $input['layer'] ?? MemoryFileRegistry::LAYER_AGENT; + $target_dir = $this->resolveLayerDirectory( $dm, $target_layer, $user_id, $agent_id ); - if ( ! $dm->ensure_directory_exists( $agent_dir ) ) { + if ( ! $dm->ensure_directory_exists( $target_dir ) ) { return array( 'success' => false, - 'error' => 'Failed to create agent directory', + 'error' => 'Failed to create target directory', ); } - $destination = "{$agent_dir}/{$file['name']}"; + $destination = "{$target_dir}/{$file['name']}"; $fs = FilesystemHelper::get(); if ( ! $fs || ! $fs->copy( $file['tmp_name'], $destination, true ) ) { return array( 'success' => false, - 'error' => 'Failed to store file in agent directory', + 'error' => 'Failed to store file', ); } @@ -551,11 +578,13 @@ public function executeUploadAgentFile( array $input ): array { // ========================================================================= /** - * Resolve a filename to its absolute path across agent layers. + * Resolve a filename to its absolute path across layers. * - * Checks the agent identity directory first, then the user directory. + * For registered files, checks the registered layer first. + * Falls back to: agent → user → shared. * * @since 0.41.0 Added $agent_id parameter for agent-first resolution. + * @since 0.42.0 Registry-aware layer resolution. * * @param DirectoryManager $dm Directory manager instance. * @param int $user_id Effective user ID. @@ -568,25 +597,55 @@ private function resolveFilePath( DirectoryManager $dm, int $user_id, string $fi 'agent_id' => $agent_id, 'user_id' => $user_id, ) ); - $agent_path = $agent_dir . '/' . $filename; - if ( file_exists( $agent_path ) ) { - return $agent_path; - } + $user_dir = $dm->get_user_directory( $user_id ); + $shared_dir = $dm->get_shared_directory(); - $user_path = $dm->get_user_directory( $user_id ) . '/' . $filename; - if ( file_exists( $user_path ) ) { - return $user_path; + // If file is registered, check its canonical layer first. + $registered_layer = MemoryFileRegistry::get_layer( $filename ); + if ( $registered_layer ) { + $primary_dir = $this->resolveLayerDirectory( $dm, $registered_layer, $user_id, $agent_id ); + $primary_path = $primary_dir . '/' . $filename; + if ( file_exists( $primary_path ) ) { + return $primary_path; + } } - // Shared layer (site-wide files like SITE.md). - $shared_path = $dm->get_shared_directory() . '/' . $filename; - if ( file_exists( $shared_path ) ) { - return $shared_path; + // Fallback: check all layers (agent → user → shared). + $search_order = array( $agent_dir, $user_dir, $shared_dir ); + foreach ( $search_order as $dir ) { + $path = $dir . '/' . $filename; + if ( file_exists( $path ) ) { + return $path; + } } return null; } + /** + * Resolve a layer identifier to its directory path. + * + * @param DirectoryManager $dm Directory manager instance. + * @param string $layer Layer identifier ('shared', 'agent', 'user'). + * @param int $user_id Effective user ID. + * @param int $agent_id Agent ID. + * @return string Directory path. + */ + private function resolveLayerDirectory( DirectoryManager $dm, string $layer, int $user_id, int $agent_id = 0 ): string { + switch ( $layer ) { + case MemoryFileRegistry::LAYER_SHARED: + return $dm->get_shared_directory(); + case MemoryFileRegistry::LAYER_USER: + return $dm->get_user_directory( $user_id ); + case MemoryFileRegistry::LAYER_AGENT: + default: + return $dm->resolve_agent_directory( array( + 'agent_id' => $agent_id, + 'user_id' => $user_id, + ) ); + } + } + /** * Normalize and escape file response entry. * @@ -610,4 +669,15 @@ private function sanitizeFileEntry( array $file ): array { return $sanitized; } + + /** + * Derive a human-readable label from a filename. + * + * @param string $filename The filename. + * @return string Label. + */ + private static function filename_to_label( string $filename ): string { + $name = pathinfo( $filename, PATHINFO_FILENAME ); + return ucwords( str_replace( array( '-', '_' ), ' ', $name ) ); + } } diff --git a/inc/Abilities/File/FileConstants.php b/inc/Abilities/File/FileConstants.php index 2ef8ddf1..80094a3a 100644 --- a/inc/Abilities/File/FileConstants.php +++ b/inc/Abilities/File/FileConstants.php @@ -1,27 +1,88 @@ { ) }` } - { ! PROTECTED_FILES.includes( file.filename ) && ! isShared && ( + { ! file.protected && ! isShared && (