diff --git a/MCPForUnity/Editor/ActionTrace/Capture/ActionTraceEventEmitter.cs b/MCPForUnity/Editor/ActionTrace/Capture/ActionTraceEventEmitter.cs
new file mode 100644
index 000000000..55ddbdb63
--- /dev/null
+++ b/MCPForUnity/Editor/ActionTrace/Capture/ActionTraceEventEmitter.cs
@@ -0,0 +1,533 @@
+using System;
+using System.Collections.Generic;
+using MCPForUnity.Editor.ActionTrace.Core;
+using MCPForUnity.Editor.Helpers;
+using UnityEngine;
+
+namespace MCPForUnity.Editor.ActionTrace.Capture
+{
+ ///
+ /// Centralized event emission layer for the ActionTrace system.
+ /// This middle layer decouples the Capture layer (Unity callbacks) from the Data layer (EventStore).
+ ///
+ /// Benefits:
+ /// - EventType constants are managed in one place
+ /// - Payload schemas are standardized
+ /// - Event naming changes only require updates here
+ /// - Capture layer code becomes simpler and more focused
+ ///
+ /// Usage:
+ /// ActionTraceEventEmitter.EmitComponentAdded(component);
+ /// ActionTraceEventEmitter.EmitAssetImported(assetPath, assetType);
+ /// ActionTraceEventEmitter.Emit("CustomEvent", targetId, payload);
+ ///
+ public static class ActionTraceEventEmitter
+ {
+ ///
+ /// Generic event emission method.
+ /// Use this for custom events or when a specific EmitXxx method doesn't exist.
+ ///
+ /// Usage:
+ /// Emit("MyCustomEvent", "target123", new Dictionary { ["key"] = "value" });
+ ///
+ public static void Emit(string eventType, string targetId, Dictionary payload)
+ {
+ EmitEvent(eventType, targetId ?? "Unknown", payload);
+ }
+
+ ///
+ /// Emit a component added event.
+ /// Uses GlobalIdHelper for cross-session stable target IDs.
+ ///
+ public static void EmitComponentAdded(Component component)
+ {
+ if (component == null)
+ {
+ McpLog.Warn("[ActionTraceEventEmitter] Attempted to emit ComponentAdded with null component");
+ return;
+ }
+
+ // Use GlobalIdHelper for cross-session stable ID
+ string globalId = GlobalIdHelper.ToGlobalIdString(component);
+
+ var payload = new Dictionary
+ {
+ ["component_type"] = component.GetType().Name,
+ ["game_object"] = component.gameObject?.name ?? "Unknown"
+ };
+
+ EmitEvent(EventTypes.ComponentAdded, globalId, payload);
+ }
+
+ ///
+ /// Emit a component removed event.
+ /// Uses GlobalIdHelper for cross-session stable target IDs.
+ ///
+ public static void EmitComponentRemoved(Component component)
+ {
+ if (component == null)
+ {
+ McpLog.Warn("[ActionTraceEventEmitter] Attempted to emit ComponentRemoved with null component");
+ return;
+ }
+
+ // Use GlobalIdHelper for cross-session stable ID
+ string globalId = GlobalIdHelper.ToGlobalIdString(component);
+
+ var payload = new Dictionary
+ {
+ ["component_type"] = component.GetType().Name,
+ ["game_object"] = component.gameObject?.name ?? "Unknown"
+ };
+
+ EmitEvent(EventTypes.ComponentRemoved, globalId, payload);
+ }
+
+ ///
+ /// Emit a GameObject created event.
+ /// Uses GlobalIdHelper for cross-session stable target IDs.
+ ///
+ public static void EmitGameObjectCreated(GameObject gameObject)
+ {
+ if (gameObject == null)
+ {
+ McpLog.Warn("[ActionTraceEventEmitter] Attempted to emit GameObjectCreated with null GameObject");
+ return;
+ }
+
+ // Use GlobalIdHelper for cross-session stable ID
+ string globalId = GlobalIdHelper.ToGlobalIdString(gameObject);
+
+ var payload = new Dictionary
+ {
+ ["name"] = gameObject.name,
+ ["instance_id"] = gameObject.GetInstanceID()
+ };
+
+ EmitEvent(EventTypes.GameObjectCreated, globalId, payload);
+ }
+
+ ///
+ /// Emit a GameObject destroyed event.
+ /// Uses GlobalIdHelper for cross-session stable target IDs.
+ ///
+ /// Call this before the GameObject is destroyed:
+ /// EmitGameObjectDestroyed(gameObject); // Preferred
+ /// EmitGameObjectDestroyed(globalId, name); // Alternative
+ ///
+ public static void EmitGameObjectDestroyed(GameObject gameObject)
+ {
+ if (gameObject == null)
+ {
+ McpLog.Warn("[ActionTraceEventEmitter] Attempted to emit GameObjectDestroyed with null GameObject");
+ return;
+ }
+
+ // Use GlobalIdHelper for cross-session stable ID
+ string globalId = GlobalIdHelper.ToGlobalIdString(gameObject);
+
+ var payload = new Dictionary
+ {
+ ["name"] = gameObject.name,
+ ["instance_id"] = gameObject.GetInstanceID()
+ };
+
+ EmitEvent(EventTypes.GameObjectDestroyed, globalId, payload);
+ }
+
+ ///
+ /// Emit a GameObject destroyed event (alternative overload for when only instanceId is available).
+ /// This overload is used when GameObject is already destroyed or unavailable.
+ ///
+ /// Priority:
+ /// 1. Use EmitGameObjectDestroyed(GameObject) when GameObject is available - provides stable GlobalId
+ /// 2. This fallback when only instanceId is known - ID may not be cross-session stable
+ ///
+ public static void EmitGameObjectDestroyed(int instanceId, string name)
+ {
+ var payload = new Dictionary
+ {
+ ["name"] = name,
+ ["instance_id"] = instanceId
+ };
+
+ // Fallback: use InstanceID when GameObject is unavailable (not cross-session stable)
+ EmitEvent(EventTypes.GameObjectDestroyed, instanceId.ToString(), payload);
+ }
+
+ ///
+ /// Emit a hierarchy changed event.
+ ///
+ public static void EmitHierarchyChanged()
+ {
+ var payload = new Dictionary
+ {
+ ["timestamp"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
+ };
+
+ EmitEvent(EventTypes.HierarchyChanged, "Scene", payload);
+ }
+
+ ///
+ /// Emit a play mode state changed event.
+ ///
+ public static void EmitPlayModeChanged(string state)
+ {
+ var payload = new Dictionary
+ {
+ ["state"] = state
+ };
+
+ EmitEvent(EventTypes.PlayModeChanged, "Editor", payload);
+ }
+
+ ///
+ /// Emit a scene saving event.
+ /// Uses Asset:{path} format for cross-session stable target IDs.
+ ///
+ public static void EmitSceneSaving(string sceneName, string path)
+ {
+ // Use scene path as stable identifier (Asset: prefix for consistency with GlobalIdHelper)
+ string targetId = string.IsNullOrEmpty(path) ? sceneName : $"Asset:{path}";
+
+ var payload = new Dictionary
+ {
+ ["scene_name"] = sceneName,
+ ["path"] = path
+ };
+
+ EmitEvent(EventTypes.SceneSaving, targetId, payload);
+ }
+
+ ///
+ /// Emit a scene saved event.
+ /// Uses Asset:{path} format for cross-session stable target IDs.
+ ///
+ public static void EmitSceneSaved(string sceneName, string path)
+ {
+ // Use scene path as stable identifier (Asset: prefix for consistency with GlobalIdHelper)
+ string targetId = string.IsNullOrEmpty(path) ? sceneName : $"Asset:{path}";
+
+ var payload = new Dictionary
+ {
+ ["scene_name"] = sceneName,
+ ["path"] = path
+ };
+
+ EmitEvent(EventTypes.SceneSaved, targetId, payload);
+ }
+
+ ///
+ /// Emit a scene opened event.
+ /// Uses Asset:{path} format for cross-session stable target IDs.
+ ///
+ public static void EmitSceneOpened(string sceneName, string path, string mode)
+ {
+ // Use scene path as stable identifier (Asset: prefix for consistency with GlobalIdHelper)
+ string targetId = string.IsNullOrEmpty(path) ? sceneName : $"Asset:{path}";
+
+ var payload = new Dictionary
+ {
+ ["scene_name"] = sceneName,
+ ["path"] = path,
+ ["mode"] = mode
+ };
+
+ EmitEvent(EventTypes.SceneOpened, targetId, payload);
+ }
+
+ ///
+ /// Emit a new scene created event.
+ ///
+ public static void EmitNewSceneCreated()
+ {
+ var payload = new Dictionary
+ {
+ ["timestamp"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
+ };
+
+ EmitEvent(EventTypes.NewSceneCreated, "Scene", payload);
+ }
+
+ ///
+ /// Emit an asset imported event.
+ /// Uses Asset:{path} format for cross-session stable target IDs.
+ ///
+ public static void EmitAssetImported(string assetPath, string assetType = null)
+ {
+ if (string.IsNullOrEmpty(assetPath))
+ {
+ McpLog.Warn("[ActionTraceEventEmitter] Attempted to emit AssetImported with null or empty path");
+ return;
+ }
+
+ string targetId = $"Asset:{assetPath}";
+
+ var payload = new Dictionary
+ {
+ ["path"] = assetPath,
+ ["extension"] = System.IO.Path.GetExtension(assetPath)
+ };
+
+ if (!string.IsNullOrEmpty(assetType))
+ {
+ payload["asset_type"] = assetType;
+ }
+ else
+ {
+ // Auto-detect asset type
+ payload["asset_type"] = DetectAssetType(assetPath);
+ }
+
+ EmitEvent(EventTypes.AssetImported, targetId, payload);
+ }
+
+ ///
+ /// Emit an asset deleted event.
+ /// Uses Asset:{path} format for cross-session stable target IDs.
+ ///
+ public static void EmitAssetDeleted(string assetPath)
+ {
+ if (string.IsNullOrEmpty(assetPath))
+ {
+ McpLog.Warn("[ActionTraceEventEmitter] Attempted to emit AssetDeleted with null or empty path");
+ return;
+ }
+
+ string targetId = $"Asset:{assetPath}";
+
+ var payload = new Dictionary
+ {
+ ["path"] = assetPath,
+ ["extension"] = System.IO.Path.GetExtension(assetPath)
+ };
+
+ EmitEvent(EventTypes.AssetDeleted, targetId, payload);
+ }
+
+ ///
+ /// Emit an asset moved event.
+ /// Uses Asset:{toPath} format for cross-session stable target IDs.
+ ///
+ public static void EmitAssetMoved(string fromPath, string toPath)
+ {
+ if (string.IsNullOrEmpty(toPath))
+ {
+ McpLog.Warn("[ActionTraceEventEmitter] Attempted to emit AssetMoved with null or empty destination path");
+ return;
+ }
+
+ string targetId = $"Asset:{toPath}";
+
+ var payload = new Dictionary
+ {
+ ["from_path"] = fromPath ?? string.Empty,
+ ["to_path"] = toPath,
+ ["extension"] = System.IO.Path.GetExtension(toPath)
+ };
+
+ EmitEvent(EventTypes.AssetMoved, targetId, payload);
+ }
+
+ ///
+ /// Emit a script compiled event.
+ ///
+ public static void EmitScriptCompiled(int scriptCount, double durationMs)
+ {
+ var payload = new Dictionary
+ {
+ ["script_count"] = scriptCount,
+ ["duration_ms"] = durationMs
+ };
+
+ EmitEvent(EventTypes.ScriptCompiled, "Scripts", payload);
+ }
+
+ ///
+ /// Emit a script compilation failed event.
+ ///
+ public static void EmitScriptCompilationFailed(int errorCount, string[] errors)
+ {
+ var payload = new Dictionary
+ {
+ ["error_count"] = errorCount,
+ ["errors"] = errors ?? Array.Empty()
+ };
+
+ EmitEvent(EventTypes.ScriptCompilationFailed, "Scripts", payload);
+ }
+
+ ///
+ /// Emit a build started event.
+ ///
+ public static void EmitBuildStarted(string platform, string buildPath)
+ {
+ var payload = new Dictionary
+ {
+ ["platform"] = platform,
+ ["build_path"] = buildPath,
+ ["timestamp"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
+ };
+
+ EmitEvent(EventTypes.BuildStarted, "Build", payload);
+ }
+
+ ///
+ /// Emit a build completed event.
+ ///
+ public static void EmitBuildCompleted(string platform, string buildPath, double durationMs, long sizeBytes)
+ {
+ var payload = new Dictionary
+ {
+ ["platform"] = platform,
+ ["build_path"] = buildPath,
+ ["duration_ms"] = durationMs,
+ ["size_bytes"] = sizeBytes
+ };
+
+ EmitEvent(EventTypes.BuildCompleted, "Build", payload);
+ }
+
+ ///
+ /// Emit a build failed event.
+ ///
+ public static void EmitBuildFailed(string platform, string errorMessage)
+ {
+ var payload = new Dictionary
+ {
+ ["platform"] = platform,
+ ["error_message"] = errorMessage
+ };
+
+ EmitEvent(EventTypes.BuildFailed, "Build", payload);
+ }
+
+ // ========================================================================
+ // Asset Modification Events (for ManageAsset integration)
+ // ========================================================================
+
+ ///
+ /// Emit an asset modified event via MCP tool (manage_asset).
+ /// Uses Asset:{path} format for cross-session stable target IDs.
+ ///
+ public static void EmitAssetModified(string assetPath, string assetType, IReadOnlyDictionary changes)
+ {
+ if (string.IsNullOrEmpty(assetPath))
+ {
+ McpLog.Warn("[ActionTraceEventEmitter] AssetModified with null path");
+ return;
+ }
+
+ string targetId = $"Asset:{assetPath}";
+
+ var payload = new Dictionary
+ {
+ ["path"] = assetPath,
+ ["asset_type"] = assetType ?? "Unknown",
+ ["changes"] = changes ?? new Dictionary(),
+ ["source"] = "mcp_tool" // Indicates this change came from an MCP tool call
+ };
+
+ EmitEvent(EventTypes.AssetModified, targetId, payload);
+ }
+
+ ///
+ /// Emit an asset created event via MCP tool (manage_asset).
+ /// Uses Asset:{path} format for cross-session stable target IDs.
+ ///
+ public static void EmitAssetCreated(string assetPath, string assetType)
+ {
+ if (string.IsNullOrEmpty(assetPath))
+ {
+ McpLog.Warn("[ActionTraceEventEmitter] AssetCreated with null path");
+ return;
+ }
+
+ string targetId = $"Asset:{assetPath}";
+
+ var payload = new Dictionary
+ {
+ ["path"] = assetPath,
+ ["asset_type"] = assetType ?? "Unknown",
+ ["source"] = "mcp_tool"
+ };
+
+ EmitEvent(EventTypes.AssetCreated, targetId, payload);
+ }
+
+ ///
+ /// Emit an asset deleted event via MCP tool (manage_asset).
+ /// Uses Asset:{path} format for cross-session stable target IDs.
+ ///
+ public static void EmitAssetDeleted(string assetPath, string assetType)
+ {
+ if (string.IsNullOrEmpty(assetPath))
+ {
+ McpLog.Warn("[ActionTraceEventEmitter] AssetDeleted with null path");
+ return;
+ }
+
+ string targetId = $"Asset:{assetPath}";
+
+ var payload = new Dictionary
+ {
+ ["path"] = assetPath,
+ ["asset_type"] = assetType ?? "Unknown",
+ ["source"] = "mcp_tool"
+ };
+
+ EmitEvent(EventTypes.AssetDeleted, targetId, payload);
+ }
+
+ ///
+ /// Core event emission method.
+ /// All events flow through this method, allowing for centralized error handling and logging.
+ ///
+ private static void EmitEvent(string eventType, string targetId, Dictionary payload)
+ {
+ try
+ {
+ var evt = new EditorEvent(
+ sequence: 0, // Will be assigned by EventStore.Record
+ timestampUnixMs: DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ type: eventType,
+ targetId: targetId,
+ payload: payload
+ );
+
+ EventStore.Record(evt);
+ }
+ catch (Exception ex)
+ {
+ McpLog.Warn($"[ActionTraceEventEmitter] Failed to emit {eventType} event: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Detect asset type from file extension.
+ ///
+ private static string DetectAssetType(string assetPath)
+ {
+ if (string.IsNullOrEmpty(assetPath))
+ return "unknown";
+
+ var extension = System.IO.Path.GetExtension(assetPath).ToLower();
+
+ return extension switch
+ {
+ ".cs" => "script",
+ ".unity" => "scene",
+ ".prefab" => "prefab",
+ ".mat" => "material",
+ ".png" or ".jpg" or ".jpeg" or ".psd" or ".tga" or ".exr" => "texture",
+ ".wav" or ".mp3" or ".ogg" or ".aif" => "audio",
+ ".fbx" or ".obj" => "model",
+ ".anim" => "animation",
+ ".controller" => "animator_controller",
+ ".shader" => "shader",
+ ".xml" or ".json" or ".yaml" => "data",
+ _ => "unknown"
+ };
+ }
+ }
+}
diff --git a/MCPForUnity/Editor/ActionTrace/Capture/AssetChangePostprocessor.cs b/MCPForUnity/Editor/ActionTrace/Capture/AssetChangePostprocessor.cs
new file mode 100644
index 000000000..390cd9f14
--- /dev/null
+++ b/MCPForUnity/Editor/ActionTrace/Capture/AssetChangePostprocessor.cs
@@ -0,0 +1,228 @@
+using System;
+using System.Collections.Generic;
+using MCPForUnity.Editor.ActionTrace.Core;
+using MCPForUnity.Editor.Helpers;
+using UnityEditor;
+using UnityEngine;
+
+namespace MCPForUnity.Editor.ActionTrace.Capture
+{
+ ///
+ /// Asset postprocessor for tracking asset changes in ActionTrace.
+ /// Uses Unity's AssetPostprocessor callback pattern, not event subscription.
+ ///
+ /// Events generated:
+ /// - AssetImported: When an asset is imported from outside
+ /// - AssetCreated: When a new asset is created in Unity
+ /// - AssetDeleted: When an asset is deleted
+ /// - AssetMoved: When an asset is moved/renamed
+ /// - AssetModified: When an existing asset is modified
+ ///
+ /// All asset events use "Asset:{path}" format for TargetId to ensure
+ /// cross-session stability.
+ ///
+ internal sealed class AssetChangePostprocessor : AssetPostprocessor
+ {
+ private static void OnPostprocessAllAssets(
+ string[] importedAssets,
+ string[] deletedAssets,
+ string[] movedAssets,
+ string[] movedFromAssetPaths)
+ {
+ // ========== Imported Assets (includes newly created assets) ==========
+ foreach (var assetPath in importedAssets)
+ {
+ if (string.IsNullOrEmpty(assetPath)) continue;
+
+ // L1 Blacklist: Skip junk assets before creating events
+ if (!EventFilter.ShouldTrackAsset(assetPath))
+ continue;
+
+ string targetId = $"Asset:{assetPath}";
+ string assetType = GetAssetType(assetPath);
+
+ var payload = new Dictionary
+ {
+ ["path"] = assetPath,
+ ["extension"] = System.IO.Path.GetExtension(assetPath),
+ ["asset_type"] = assetType
+ };
+
+ // Distinguish between imported and newly created assets
+ if (IsNewlyCreatedAsset(assetPath))
+ {
+ RecordEvent(EventTypes.AssetCreated, targetId, payload);
+ }
+ else
+ {
+ RecordEvent(EventTypes.AssetImported, targetId, payload);
+ }
+ }
+
+ // ========== Deleted Assets ==========
+ foreach (var assetPath in deletedAssets)
+ {
+ if (string.IsNullOrEmpty(assetPath)) continue;
+
+ // L1 Blacklist: Skip junk assets
+ if (!EventFilter.ShouldTrackAsset(assetPath))
+ continue;
+
+ string targetId = $"Asset:{assetPath}";
+
+ var payload = new Dictionary
+ {
+ ["path"] = assetPath
+ };
+
+ RecordEvent(EventTypes.AssetDeleted, targetId, payload);
+ }
+
+ // ========== Moved Assets ==========
+ for (int i = 0; i < movedAssets.Length; i++)
+ {
+ if (string.IsNullOrEmpty(movedAssets[i])) continue;
+
+ var fromPath = i < movedFromAssetPaths.Length ? movedFromAssetPaths[i] : "";
+
+ // L1 Blacklist: Skip junk assets
+ if (!EventFilter.ShouldTrackAsset(movedAssets[i]))
+ continue;
+
+ string targetId = $"Asset:{movedAssets[i]}";
+
+ var payload = new Dictionary
+ {
+ ["to_path"] = movedAssets[i],
+ ["from_path"] = fromPath
+ };
+
+ RecordEvent(EventTypes.AssetMoved, targetId, payload);
+ }
+
+ // ========== Modified Assets ==========
+ // Track asset modifications separately (e.g., texture imports, prefab changes)
+ foreach (var assetPath in importedAssets)
+ {
+ if (string.IsNullOrEmpty(assetPath)) continue;
+
+ // Only track modifications for existing assets
+ if (!IsNewlyCreatedAsset(assetPath) && EventFilter.ShouldTrackAsset(assetPath))
+ {
+ string targetId = $"Asset:{assetPath}";
+ string assetType = GetAssetType(assetPath);
+
+ // Only record modifications for certain asset types
+ if (ShouldTrackModification(assetPath))
+ {
+ var payload = new Dictionary
+ {
+ ["path"] = assetPath,
+ ["extension"] = System.IO.Path.GetExtension(assetPath),
+ ["asset_type"] = assetType
+ };
+
+ RecordEvent(EventTypes.AssetModified, targetId, payload);
+ }
+ }
+ }
+ }
+
+ ///
+ /// Determines if an asset was newly created vs imported.
+ /// Newly created assets have a .meta file with recent creation time.
+ ///
+ private static bool IsNewlyCreatedAsset(string assetPath)
+ {
+ try
+ {
+ string metaPath = assetPath + ".meta";
+ var meta = AssetDatabase.LoadMainAssetAtPath(metaPath);
+ // This is a simplified check - in production you'd check file creation time
+ return false; // Default to treating as imported for now
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ ///
+ /// Determines if modifications to this asset type should be tracked.
+ /// Tracks modifications for commonly edited asset types.
+ ///
+ private static bool ShouldTrackModification(string assetPath)
+ {
+ string ext = System.IO.Path.GetExtension(assetPath).ToLower();
+ // Track modifications for these asset types
+ return ext == ".png" || ext == ".jpg" || ext == ".jpeg" ||
+ ext == ".psd" || ext == ".tif" ||
+ ext == ".fbx" || ext == ".obj" ||
+ ext == ".prefab" || ext == ".unity" ||
+ ext == ".anim" || ext == ".controller";
+ }
+
+ ///
+ /// Gets the asset type based on file extension.
+ ///
+ private static string GetAssetType(string assetPath)
+ {
+ string ext = System.IO.Path.GetExtension(assetPath).ToLower();
+ return ext switch
+ {
+ ".cs" => "script",
+ ".unity" => "scene",
+ ".prefab" => "prefab",
+ ".mat" => "material",
+ ".png" or ".jpg" or ".jpeg" or ".gif" or ".tga" or ".psd" or ".tif" or ".bmp" => "texture",
+ ".fbx" or ".obj" or ".blend" or ".3ds" => "model",
+ ".anim" => "animation",
+ ".controller" => "animator_controller",
+ ".shader" => "shader",
+ ".asset" => "scriptable_object",
+ ".physicmaterial" => "physics_material",
+ ".physicmaterial2d" => "physics_material_2d",
+ ".guiskin" => "gui_skin",
+ ".fontsettings" => "font",
+ ".mixer" => "audio_mixer",
+ ".rendertexture" => "render_texture",
+ ".spriteatlas" => "sprite_atlas",
+ ".tilepalette" => "tile_palette",
+ _ => "unknown"
+ };
+ }
+
+ ///
+ /// Records an event to the EventStore with proper context injection.
+ ///
+ private static void RecordEvent(string type, string targetId, Dictionary payload)
+ {
+ try
+ {
+ // Inject VCS context into all recorded events
+ var vcsContext = VCS.VcsContextProvider.GetCurrentContext();
+ payload["vcs_context"] = vcsContext.ToDictionary();
+
+ // Inject Undo Group ID for undo_to_sequence functionality (P2.4)
+ int currentUndoGroup = Undo.GetCurrentGroup();
+ payload["undo_group"] = currentUndoGroup;
+
+ var evt = new EditorEvent(
+ sequence: 0,
+ timestampUnixMs: DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ type: type,
+ targetId: targetId,
+ payload: payload
+ );
+
+ // AssetPostprocessor callbacks run on main thread but outside update loop.
+ // Use delayCall to defer recording to main thread update, avoiding thread warnings.
+ UnityEditor.EditorApplication.delayCall += () => Core.EventStore.Record(evt);
+ }
+ catch (Exception ex)
+ {
+ McpLog.Warn($"[AssetChangePostprocessor] Failed to record event: {ex.Message}");
+ }
+ }
+ }
+}
diff --git a/MCPForUnity/Editor/ActionTrace/Capture/EventFilter.cs b/MCPForUnity/Editor/ActionTrace/Capture/EventFilter.cs
new file mode 100644
index 000000000..9632987f7
--- /dev/null
+++ b/MCPForUnity/Editor/ActionTrace/Capture/EventFilter.cs
@@ -0,0 +1,447 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.RegularExpressions;
+using UnityEngine;
+using UnityEditor;
+
+namespace MCPForUnity.Editor.ActionTrace.Capture
+{
+ ///
+ /// Rule-based filter configuration for event filtering.
+ /// Rules are evaluated in order; first match wins.
+ ///
+ [Serializable]
+ public sealed class FilterRule
+ {
+ public string Name;
+ public bool Enabled = true;
+
+ [Tooltip("Rule type: Prefix=Directory prefix match, Extension=File extension, Regex=Regular expression, GameObject=GameObject name")]
+ public RuleType Type;
+
+ [Tooltip("Pattern to match (e.g., 'Library/', '.meta', '.*\\.tmp$')")]
+ public string Pattern;
+
+ [Tooltip("Action when matched: Block=Filter out, Allow=Allow through")]
+ public FilterAction Action = FilterAction.Block;
+
+ [Tooltip("Priority for conflict resolution. Higher values evaluated first.")]
+ public int Priority;
+
+ [NonSerialized]
+ private Regex _cachedRegex;
+
+ private Regex GetRegex()
+ {
+ if (_cachedRegex != null) return _cachedRegex;
+ if (!string.IsNullOrEmpty(Pattern))
+ {
+ try
+ {
+ _cachedRegex = new Regex(Pattern, RegexOptions.Compiled | RegexOptions.IgnoreCase);
+ }
+ catch
+ {
+ // Invalid regex, return null
+ }
+ }
+ return _cachedRegex;
+ }
+
+ public bool Matches(string path, string gameObjectName)
+ {
+ if (string.IsNullOrEmpty(Pattern)) return false;
+
+ return Type switch
+ {
+ RuleType.Prefix => path?.StartsWith(Pattern, StringComparison.OrdinalIgnoreCase) == true,
+ RuleType.Extension => path?.EndsWith(Pattern, StringComparison.OrdinalIgnoreCase) == true,
+ RuleType.Regex => GetRegex()?.IsMatch(path ?? "") == true,
+ RuleType.GameObject => GetRegex()?.IsMatch(gameObjectName ?? "") == true
+ || gameObjectName?.Equals(Pattern, StringComparison.OrdinalIgnoreCase) == true,
+ _ => false
+ };
+ }
+
+ public void InvalidateCache()
+ {
+ _cachedRegex = null;
+ }
+ }
+
+ ///
+ /// Types of filter rules.
+ ///
+ public enum RuleType
+ {
+ Prefix, // Directory prefix matching (fast)
+ Extension, // File extension matching (fast)
+ Regex, // Full regex pattern (slow, flexible)
+ GameObject // GameObject name matching
+ }
+
+ ///
+ /// Filter action when a rule matches.
+ ///
+ public enum FilterAction
+ {
+ Block, // Filter out the event
+ Allow // Allow the event through
+ }
+
+ ///
+ /// Configurable event filter settings.
+ /// Stored as part of ActionTraceSettings for persistence.
+ ///
+ [Serializable]
+ public sealed class EventFilterSettings
+ {
+ [Tooltip("Custom filter rules. Evaluated in priority order.")]
+ public List CustomRules = new();
+
+ [Tooltip("Enable default junk filters (Library/, Temp/, etc.)")]
+ public bool EnableDefaultFilters = true;
+
+ [Tooltip("Enable special handling for .meta files")]
+ public bool EnableMetaFileHandling = true;
+
+ [Tooltip("Minimum GameObject name length to avoid filtering unnamed objects")]
+ public int MinGameObjectNameLength = 2;
+
+ ///
+ /// Get default built-in filter rules.
+ /// These are always active when EnableDefaultFilters is true.
+ ///
+ public static readonly List DefaultRules = new()
+ {
+ new() { Name = "Library Directory", Type = RuleType.Prefix, Pattern = "Library/", Action = FilterAction.Block, Priority = 100 },
+ new() { Name = "Temp Directory", Type = RuleType.Prefix, Pattern = "Temp/", Action = FilterAction.Block, Priority = 100 },
+ new() { Name = "obj Directory", Type = RuleType.Prefix, Pattern = "obj/", Action = FilterAction.Block, Priority = 100 },
+ new() { Name = "Logs Directory", Type = RuleType.Prefix, Pattern = "Logs/", Action = FilterAction.Block, Priority = 100 },
+ new() { Name = "__pycache__", Type = RuleType.Regex, Pattern = @"__pycache__", Action = FilterAction.Block, Priority = 100 },
+ new() { Name = ".git Directory", Type = RuleType.Prefix, Pattern = ".git/", Action = FilterAction.Block, Priority = 100 },
+ new() { Name = ".vs Directory", Type = RuleType.Prefix, Pattern = ".vs/", Action = FilterAction.Block, Priority = 100 },
+ new() { Name = ".pyc Files", Type = RuleType.Extension, Pattern = ".pyc", Action = FilterAction.Block, Priority = 90 },
+ new() { Name = ".pyo Files", Type = RuleType.Extension, Pattern = ".pyo", Action = FilterAction.Block, Priority = 90 },
+ new() { Name = ".tmp Files", Type = RuleType.Extension, Pattern = ".tmp", Action = FilterAction.Block, Priority = 90 },
+ new() { Name = ".temp Files", Type = RuleType.Extension, Pattern = ".temp", Action = FilterAction.Block, Priority = 90 },
+ new() { Name = ".cache Files", Type = RuleType.Extension, Pattern = ".cache", Action = FilterAction.Block, Priority = 90 },
+ new() { Name = ".bak Files", Type = RuleType.Extension, Pattern = ".bak", Action = FilterAction.Block, Priority = 90 },
+ new() { Name = ".swp Files", Type = RuleType.Extension, Pattern = ".swp", Action = FilterAction.Block, Priority = 90 },
+ new() { Name = ".DS_Store", Type = RuleType.Extension, Pattern = ".DS_Store", Action = FilterAction.Block, Priority = 90 },
+ new() { Name = "Thumbs.db", Type = RuleType.Extension, Pattern = "Thumbs.db", Action = FilterAction.Block, Priority = 90 },
+ new() { Name = ".csproj Files", Type = RuleType.Extension, Pattern = ".csproj", Action = FilterAction.Block, Priority = 80 },
+ new() { Name = ".sln Files", Type = RuleType.Extension, Pattern = ".sln", Action = FilterAction.Block, Priority = 80 },
+ new() { Name = ".suo Files", Type = RuleType.Extension, Pattern = ".suo", Action = FilterAction.Block, Priority = 80 },
+ new() { Name = ".user Files", Type = RuleType.Extension, Pattern = ".user", Action = FilterAction.Block, Priority = 80 },
+ new() { Name = "Unnamed GameObjects", Type = RuleType.Regex, Pattern = @"^GameObject\d+$", Action = FilterAction.Block, Priority = 70 },
+ new() { Name = "Generated Colliders", Type = RuleType.Regex, Pattern = @"^Collider\d+$", Action = FilterAction.Block, Priority = 70 },
+ new() { Name = "EditorOnly Objects", Type = RuleType.Prefix, Pattern = "EditorOnly", Action = FilterAction.Block, Priority = 70 },
+ };
+
+ ///
+ /// Add a new custom rule.
+ ///
+ public FilterRule AddRule(string name, RuleType type, string pattern, FilterAction action, int priority = 50)
+ {
+ var rule = new FilterRule
+ {
+ Name = name,
+ Type = type,
+ Pattern = pattern,
+ Action = action,
+ Priority = priority,
+ Enabled = true
+ };
+ CustomRules.Add(rule);
+ return rule;
+ }
+
+ ///
+ /// Remove a rule by name.
+ ///
+ public bool RemoveRule(string name)
+ {
+ var rule = CustomRules.Find(r => r.Name == name);
+ if (rule != null)
+ {
+ CustomRules.Remove(rule);
+ return true;
+ }
+ return false;
+ }
+
+ ///
+ /// Get all active rules (default + custom, sorted by priority).
+ ///
+ public List GetActiveRules()
+ {
+ var rules = new List();
+
+ if (EnableDefaultFilters)
+ {
+ // Manual loop instead of LINQ Where to avoid allocation in hot path
+ foreach (var rule in DefaultRules)
+ {
+ if (rule.Enabled)
+ rules.Add(rule);
+ }
+ }
+
+ // Manual loop instead of LINQ Where to avoid allocation in hot path
+ foreach (var rule in CustomRules)
+ {
+ if (rule.Enabled)
+ rules.Add(rule);
+ }
+
+ // Sort by priority descending (higher priority first)
+ rules.Sort((a, b) => b.Priority.CompareTo(a.Priority));
+
+ return rules;
+ }
+ }
+
+ ///
+ /// First line of defense: Capture-layer blacklist to filter out system junk.
+ ///
+ /// Philosophy: Blacklist at capture layer = "Record everything EXCEPT known garbage"
+ /// - Preserves serendipity: AI can see unexpected but important changes
+ /// - Protects memory: Prevents EventStore from filling with junk entries
+ ///
+ /// The filter now supports configurable rules via EventFilterSettings.
+ /// Default rules are always applied unless explicitly disabled.
+ /// Custom rules can be added for project-specific filtering.
+ ///
+ public static class EventFilter
+ {
+ private static EventFilterSettings _settings;
+
+ ///
+ /// Current filter settings.
+ /// If null, default settings will be used.
+ ///
+ public static EventFilterSettings Settings
+ {
+ get => _settings ??= new EventFilterSettings();
+ set => _settings = value;
+ }
+
+ ///
+ /// Reset to default settings.
+ ///
+ public static void ResetToDefaults()
+ {
+ _settings = new EventFilterSettings();
+ }
+
+ // ========== Public API ==========
+
+ ///
+ /// Determines if a given path should be filtered as junk.
+ ///
+ /// Uses configured rules, evaluated in priority order.
+ /// First matching rule decides the outcome.
+ ///
+ /// Returns: true if the path should be filtered out, false otherwise.
+ ///
+ public static bool IsJunkPath(string path)
+ {
+ if (string.IsNullOrEmpty(path))
+ return false;
+
+ var rules = Settings.GetActiveRules();
+
+ foreach (var rule in rules)
+ {
+ if (rule.Matches(path, null))
+ {
+ return rule.Action == FilterAction.Block;
+ }
+ }
+
+ return false; // Default: allow through
+ }
+
+ ///
+ /// Checks if an asset path should generate an event.
+ /// This includes additional logic for assets beyond path filtering.
+ ///
+ public static bool ShouldTrackAsset(string assetPath)
+ {
+ if (string.IsNullOrEmpty(assetPath))
+ return true;
+
+ // Check base junk filter
+ if (IsJunkPath(assetPath))
+ return false;
+
+ // Special handling for .meta files
+ if (Settings.EnableMetaFileHandling && assetPath.EndsWith(".meta", StringComparison.OrdinalIgnoreCase))
+ {
+ string basePath = assetPath.Substring(0, assetPath.Length - 5);
+
+ // Track .meta for important asset types
+ if (basePath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase) ||
+ basePath.EndsWith(".unity", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ return false; // Skip .meta for everything else
+ }
+
+ // Never filter assets in Resources folder
+ if (assetPath.Contains("/Resources/", StringComparison.OrdinalIgnoreCase))
+ return true;
+
+ return true;
+ }
+
+ ///
+ /// Checks if a GameObject name should be filtered.
+ ///
+ public static bool IsJunkGameObject(string name)
+ {
+ if (string.IsNullOrEmpty(name))
+ return false;
+
+ // Check minimum length
+ if (name.Length < Settings.MinGameObjectNameLength)
+ return true;
+
+ var rules = Settings.GetActiveRules();
+
+ foreach (var rule in rules)
+ {
+ // Only check GameObject-specific rules
+ if (rule.Type == RuleType.GameObject || rule.Type == RuleType.Regex)
+ {
+ if (rule.Matches(null, name))
+ {
+ return rule.Action == FilterAction.Block;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ // ========== Runtime Configuration ==========
+
+ ///
+ /// Adds a custom filter rule at runtime.
+ ///
+ public static FilterRule AddRule(string name, RuleType type, string pattern, FilterAction action, int priority = 50)
+ {
+ return Settings.AddRule(name, type, pattern, action, priority);
+ }
+
+ ///
+ /// Adds a junk directory prefix at runtime.
+ ///
+ public static void AddJunkDirectoryPrefix(string prefix)
+ {
+ AddRule($"Custom: {prefix}", RuleType.Prefix, prefix, FilterAction.Block, 50);
+ }
+
+ ///
+ /// Adds a junk file extension at runtime.
+ ///
+ public static void AddJunkExtension(string extension)
+ {
+ string ext = extension.StartsWith(".") ? extension : $".{extension}";
+ AddRule($"Custom: {ext}", RuleType.Extension, ext, FilterAction.Block, 50);
+ }
+
+ ///
+ /// Adds a regex pattern for junk matching at runtime.
+ ///
+ public static void AddJunkPattern(string regexPattern)
+ {
+ AddRule($"Custom Regex: {regexPattern}", RuleType.Regex, regexPattern, FilterAction.Block, 50);
+ }
+
+ ///
+ /// Allow a specific path pattern (create an allow rule).
+ ///
+ public static void AllowPath(string pattern, int priority = 60)
+ {
+ AddRule($"Allow: {pattern}", RuleType.Regex, pattern, FilterAction.Allow, priority);
+ }
+
+ // ========== Diagnostic Info ==========
+
+ ///
+ /// Gets diagnostic information about the filter configuration.
+ ///
+ public static string GetDiagnosticInfo()
+ {
+ var rules = Settings.GetActiveRules();
+ int blockRules = 0;
+ int allowRules = 0;
+ // Manual count instead of LINQ Count to avoid allocation
+ foreach (var rule in rules)
+ {
+ if (rule.Action == FilterAction.Block)
+ blockRules++;
+ else if (rule.Action == FilterAction.Allow)
+ allowRules++;
+ }
+
+ return $"EventFilter Configuration:\n" +
+ $" - Default Filters: {(Settings.EnableDefaultFilters ? "Enabled" : "Disabled")}\n" +
+ $" - Meta File Handling: {(Settings.EnableMetaFileHandling ? "Enabled" : "Disabled")}\n" +
+ $" - Total Rules: {rules.Count}\n" +
+ $" - Block Rules: {blockRules}\n" +
+ $" - Allow Rules: {allowRules}\n" +
+ $" - Custom Rules: {Settings.CustomRules.Count}";
+ }
+
+ ///
+ /// Test a path against all rules and return the result.
+ /// Useful for debugging filter behavior.
+ ///
+ public static (bool filtered, FilterRule matchingRule) TestPath(string path)
+ {
+ if (string.IsNullOrEmpty(path))
+ return (false, null);
+
+ var rules = Settings.GetActiveRules();
+
+ foreach (var rule in rules)
+ {
+ if (rule.Matches(path, null))
+ {
+ return (rule.Action == FilterAction.Block, rule);
+ }
+ }
+
+ return (false, null);
+ }
+
+ ///
+ /// Get all rules that would match a given path.
+ ///
+ public static List<(FilterRule rule, bool wouldBlock)> GetMatchingRules(string path)
+ {
+ var result = new List<(FilterRule, bool)>();
+
+ if (string.IsNullOrEmpty(path))
+ return result;
+
+ var rules = Settings.GetActiveRules();
+
+ foreach (var rule in rules)
+ {
+ if (rule.Matches(path, null))
+ {
+ result.Add((rule, rule.Action == FilterAction.Block));
+ }
+ }
+
+ return result;
+ }
+ }
+}
diff --git a/MCPForUnity/Editor/ActionTrace/Capture/IEventCapturePoint.cs b/MCPForUnity/Editor/ActionTrace/Capture/IEventCapturePoint.cs
new file mode 100644
index 000000000..6455c3881
--- /dev/null
+++ b/MCPForUnity/Editor/ActionTrace/Capture/IEventCapturePoint.cs
@@ -0,0 +1,414 @@
+using System;
+using System.Collections.Generic;
+using MCPForUnity.Editor.ActionTrace.Core;
+using MCPForUnity.Editor.ActionTrace.Context;
+using UnityEngine;
+using UnityEditor;
+
+namespace MCPForUnity.Editor.ActionTrace.Capture
+{
+ ///
+ /// Defines a point in the editor where events can be captured.
+ ///
+ /// This interface unifies all event capture sources:
+ /// - Unity callbacks (EditorApplication events)
+ /// - Asset postprocessors
+ /// - Component change tracking
+ /// - Custom tool invocations
+ ///
+ /// Implementations should be lightweight and focus on event capture,
+ /// delegating filtering, sampling, and storage to the middleware pipeline.
+ ///
+ public interface IEventCapturePoint
+ {
+ ///
+ /// Unique identifier for this capture point.
+ /// Used for diagnostics and configuration.
+ ///
+ string CapturePointId { get; }
+
+ ///
+ /// Human-readable description of what this capture point monitors.
+ ///
+ string Description { get; }
+
+ ///
+ /// Priority for initialization (higher = earlier).
+ /// Useful for dependencies between capture points.
+ ///
+ int InitializationPriority { get; }
+
+ ///
+ /// Whether this capture point is currently enabled.
+ ///
+ bool IsEnabled { get; set; }
+
+ ///
+ /// Initialize the capture point.
+ /// Called when ActionTrace system starts.
+ ///
+ void Initialize();
+
+ ///
+ /// Shutdown the capture point.
+ /// Called when ActionTrace system stops or domain reloads.
+ ///
+ void Shutdown();
+
+ ///
+ /// Get diagnostic information about this capture point.
+ /// Useful for debugging and monitoring.
+ ///
+ string GetDiagnosticInfo();
+
+ ///
+ /// Get statistics about captured events.
+ ///
+ CapturePointStats GetStats();
+ }
+
+ ///
+ /// Statistics for a capture point.
+ ///
+ [Serializable]
+ public sealed class CapturePointStats
+ {
+ public int TotalEventsCaptured;
+ public int EventsFiltered;
+ public int EventsSampled;
+ public long TotalCaptureTimeMs;
+ public double AverageCaptureTimeMs;
+ public int ErrorCount;
+
+ private long _startTimeTicks;
+
+ public void StartCapture()
+ {
+ _startTimeTicks = DateTimeOffset.UtcNow.Ticks;
+ }
+
+ public void EndCapture()
+ {
+ long elapsedTicks = DateTimeOffset.UtcNow.Ticks - _startTimeTicks;
+ TotalCaptureTimeMs += elapsedTicks / 10000;
+ TotalEventsCaptured++;
+ UpdateAverage();
+ }
+
+ public void RecordFiltered()
+ {
+ EventsFiltered++;
+ }
+
+ public void RecordSampled()
+ {
+ EventsSampled++;
+ }
+
+ public void RecordError()
+ {
+ ErrorCount++;
+ }
+
+ public void UpdateAverage()
+ {
+ AverageCaptureTimeMs = TotalEventsCaptured > 0
+ ? (double)TotalCaptureTimeMs / TotalEventsCaptured
+ : 0;
+ }
+
+ public void Reset()
+ {
+ TotalEventsCaptured = 0;
+ EventsFiltered = 0;
+ EventsSampled = 0;
+ TotalCaptureTimeMs = 0;
+ AverageCaptureTimeMs = 0;
+ ErrorCount = 0;
+ }
+ }
+
+ ///
+ /// Base class for capture points with common functionality.
+ ///
+ public abstract class EventCapturePointBase : IEventCapturePoint
+ {
+ private readonly CapturePointStats _stats = new();
+ private bool _isEnabled = true;
+
+ public abstract string CapturePointId { get; }
+ public abstract string Description { get; }
+ public virtual int InitializationPriority => 0;
+
+ public virtual bool IsEnabled
+ {
+ get => _isEnabled;
+ set => _isEnabled = value;
+ }
+
+ public virtual void Initialize() { }
+ public virtual void Shutdown() { }
+
+ public virtual string GetDiagnosticInfo()
+ {
+ return $"[{CapturePointId}] {Description}\n" +
+ $" Enabled: {IsEnabled}\n" +
+ $" Events: {_stats.TotalEventsCaptured} captured, {_stats.EventsFiltered} filtered, {_stats.EventsSampled} sampled\n" +
+ $" Avg Capture Time: {_stats.AverageCaptureTimeMs:F3}ms\n" +
+ $" Errors: {_stats.ErrorCount}";
+ }
+
+ public virtual CapturePointStats GetStats() => _stats;
+
+ ///
+ /// Record an event through the capture pipeline.
+ /// This method handles filtering, sampling, and storage.
+ ///
+ protected void RecordEvent(EditorEvent evt, ContextMapping context = null)
+ {
+ if (!IsEnabled) return;
+
+ _stats.StartCapture();
+
+ try
+ {
+ // Create event and record via EventStore
+ EventStore.Record(evt);
+ _stats.EndCapture();
+ }
+ catch (Exception ex)
+ {
+ _stats.RecordError();
+ Debug.LogError($"[{CapturePointId}] Error recording event: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Record a filtered event (doesn't count towards captured stats).
+ ///
+ protected void RecordFiltered()
+ {
+ _stats.RecordFiltered();
+ }
+
+ ///
+ /// Record a sampled event (counted as sampled, not captured).
+ ///
+ protected void RecordSampled()
+ {
+ _stats.RecordSampled();
+ }
+
+ ///
+ /// Reset statistics.
+ ///
+ public void ResetStats()
+ {
+ _stats.Reset();
+ }
+ }
+
+ ///
+ /// Registry for all event capture points.
+ /// Manages lifecycle and provides access for diagnostics.
+ ///
+ public sealed class EventCaptureRegistry
+ {
+ private static readonly Lazy _instance =
+ new(() => new EventCaptureRegistry());
+
+ private readonly List _capturePoints = new();
+ private bool _isInitialized;
+
+ public static EventCaptureRegistry Instance => _instance.Value;
+
+ private EventCaptureRegistry() { }
+
+ ///
+ /// Register a capture point.
+ /// Should be called during initialization, before Start().
+ ///
+ public void Register(IEventCapturePoint capturePoint)
+ {
+ if (capturePoint == null) return;
+
+ _capturePoints.Add(capturePoint);
+
+ // Sort by priority
+ _capturePoints.Sort((a, b) => b.InitializationPriority.CompareTo(a.InitializationPriority));
+ }
+
+ ///
+ /// Unregister a capture point.
+ ///
+ public bool Unregister(string capturePointId)
+ {
+ var point = _capturePoints.Find(p => p.CapturePointId == capturePointId);
+ if (point != null)
+ {
+ if (_isInitialized)
+ point.Shutdown();
+ _capturePoints.Remove(point);
+ return true;
+ }
+ return false;
+ }
+
+ ///
+ /// Initialize all registered capture points.
+ ///
+ public void InitializeAll()
+ {
+ if (_isInitialized) return;
+
+ foreach (var point in _capturePoints)
+ {
+ try
+ {
+ point.Initialize();
+ }
+ catch (Exception ex)
+ {
+ Debug.LogError($"[EventCaptureRegistry] Failed to initialize {point.CapturePointId}: {ex.Message}");
+ }
+ }
+
+ _isInitialized = true;
+ Debug.Log($"[EventCaptureRegistry] Initialized {_capturePoints.Count} capture points");
+ }
+
+ ///
+ /// Shutdown all registered capture points.
+ ///
+ public void ShutdownAll()
+ {
+ if (!_isInitialized) return;
+
+ // Shutdown in reverse order
+ for (int i = _capturePoints.Count - 1; i >= 0; i--)
+ {
+ try
+ {
+ _capturePoints[i].Shutdown();
+ }
+ catch (Exception ex)
+ {
+ Debug.LogError($"[EventCaptureRegistry] Failed to shutdown {_capturePoints[i].CapturePointId}: {ex.Message}");
+ }
+ }
+
+ _isInitialized = false;
+ }
+
+ ///
+ /// Get a capture point by ID.
+ ///
+ public IEventCapturePoint GetCapturePoint(string id)
+ {
+ return _capturePoints.Find(p => p.CapturePointId == id);
+ }
+
+ ///
+ /// Get all registered capture points.
+ ///
+ public IReadOnlyList GetAllCapturePoints()
+ {
+ return _capturePoints.AsReadOnly();
+ }
+
+ ///
+ /// Get enabled capture points.
+ ///
+ public IReadOnlyList GetEnabledCapturePoints()
+ {
+ return _capturePoints.FindAll(p => p.IsEnabled).AsReadOnly();
+ }
+
+ ///
+ /// Get diagnostic information for all capture points.
+ ///
+ public string GetDiagnosticInfo()
+ {
+ var sb = new System.Text.StringBuilder();
+ sb.AppendLine($"Event Capture Registry - {_capturePoints.Count} points registered:");
+ sb.AppendLine($"Initialized: {_isInitialized}");
+
+ foreach (var point in _capturePoints)
+ {
+ sb.AppendLine();
+ sb.AppendLine(point.GetDiagnosticInfo());
+ }
+
+ return sb.ToString();
+ }
+
+ ///
+ /// Get aggregated statistics from all capture points.
+ ///
+ public CapturePointStats GetAggregatedStats()
+ {
+ var aggregated = new CapturePointStats();
+
+ foreach (var point in _capturePoints)
+ {
+ var stats = point.GetStats();
+ aggregated.TotalEventsCaptured += stats.TotalEventsCaptured;
+ aggregated.EventsFiltered += stats.EventsFiltered;
+ aggregated.EventsSampled += stats.EventsSampled;
+ aggregated.TotalCaptureTimeMs += stats.TotalCaptureTimeMs;
+ aggregated.ErrorCount += stats.ErrorCount;
+ }
+
+ aggregated.UpdateAverage();
+ return aggregated;
+ }
+
+ ///
+ /// Enable or disable a capture point by ID.
+ ///
+ public bool SetEnabled(string id, bool enabled)
+ {
+ var point = GetCapturePoint(id);
+ if (point != null)
+ {
+ point.IsEnabled = enabled;
+ return true;
+ }
+ return false;
+ }
+ }
+
+ ///
+ /// Attribute to mark a class as an event capture point.
+ /// Used for auto-discovery during initialization.
+ ///
+ [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
+ public sealed class EventCapturePointAttribute : Attribute
+ {
+ public string Id { get; }
+ public string Description { get; }
+ public int Priority { get; }
+
+ public EventCapturePointAttribute(string id, string description = null, int priority = 0)
+ {
+ Id = id;
+ Description = description ?? id;
+ Priority = priority;
+ }
+ }
+
+ ///
+ /// Built-in capture point identifiers.
+ ///
+ public static class BuiltInCapturePoints
+ {
+ public const string UnityCallbacks = "UnityCallbacks";
+ public const string AssetPostprocessor = "AssetPostprocessor";
+ public const string PropertyTracking = "PropertyTracking";
+ public const string SelectionTracking = "SelectionTracking";
+ public const string HierarchyTracking = "HierarchyTracking";
+ public const string BuildTracking = "BuildTracking";
+ public const string CompilationTracking = "CompilationTracking";
+ public const string ToolInvocation = "ToolInvocation";
+ }
+}
diff --git a/MCPForUnity/Editor/ActionTrace/Capture/PropertyChangeTracker.cs b/MCPForUnity/Editor/ActionTrace/Capture/PropertyChangeTracker.cs
new file mode 100644
index 000000000..c5747d3d5
--- /dev/null
+++ b/MCPForUnity/Editor/ActionTrace/Capture/PropertyChangeTracker.cs
@@ -0,0 +1,305 @@
+using System;
+using System.Collections.Generic;
+using UnityEditor;
+using MCPForUnity.Editor.ActionTrace.Core;
+using MCPForUnity.Editor.Helpers;
+using MCPForUnity.Editor.ActionTrace.Helpers;
+
+namespace MCPForUnity.Editor.ActionTrace.Capture
+{
+ ///
+ /// High-performance property change tracker with debouncing.
+ ///
+ /// Captures Unity property modifications via Undo.postprocessModifications,
+ /// applies debouncing to merge rapid changes (e.g., Slider drag), and records
+ /// PropertyModified events to the ActionTrace EventStore.
+ ///
+ /// Key features:
+ /// - Uses EditorApplication.update for periodic flushing (safe on domain reload)
+ /// - Object pooling to reduce GC pressure
+ /// - Cache size limits to prevent unbounded memory growth
+ /// - Cross-session stable IDs via GlobalIdHelper
+ ///
+ /// Reuses existing Helpers:
+ /// - GlobalIdHelper.ToGlobalIdString() for stable object IDs
+ /// - PropertyFormatter for property value formatting
+ /// - UndoReflectionHelper for Undo reflection logic
+ ///
+ [InitializeOnLoad]
+ public static class PropertyChangeTracker
+ {
+ // Configuration
+ private const long DebounceWindowMs = 500; // Debounce window in milliseconds
+ private const int MaxPendingEntries = 256; // Max pending changes before forced flush
+
+ // State
+ private static readonly Dictionary _pendingChanges = new();
+ private static readonly Stack _objectPool = new();
+ private static readonly HashSet _removedKeys = new();
+ private static double _lastFlushTime;
+
+ ///
+ /// Initializes the property tracker and subscribes to Unity callbacks.
+ ///
+ static PropertyChangeTracker()
+ {
+ Undo.postprocessModifications += mods => ProcessModifications(mods);
+ ScheduleNextFlush();
+ }
+
+ ///
+ /// Schedules periodic flush checks using EditorApplication.update.
+ /// FlushCheck is called every frame but only processes when debounce window expires.
+ ///
+ private static void ScheduleNextFlush()
+ {
+ // Use EditorApplication.update instead of delayCall to avoid infinite recursion
+ // This ensures the callback is properly cleaned up on domain reload
+ EditorApplication.update -= FlushCheck;
+ EditorApplication.update += FlushCheck;
+ }
+
+ ///
+ /// Periodic flush check called by EditorApplication.update.
+ /// Only performs flush when the debounce window has expired.
+ ///
+ private static void FlushCheck()
+ {
+ var currentTime = EditorApplication.timeSinceStartup * 1000;
+
+ if (currentTime - _lastFlushTime >= DebounceWindowMs)
+ {
+ FlushPendingChanges();
+ _lastFlushTime = currentTime;
+ }
+ }
+
+ ///
+ /// Called by Unity when properties are modified via Undo system.
+ /// This includes Inspector changes, Scene view manipulations, etc.
+ /// Returns the modifications unchanged to allow Undo system to continue.
+ ///
+ private static UndoPropertyModification[] ProcessModifications(UndoPropertyModification[] modifications)
+ {
+ if (modifications == null || modifications.Length == 0)
+ return modifications;
+
+ foreach (var undoMod in modifications)
+ {
+ // UndoPropertyModification contains the PropertyModification and value changes
+ // Try to extract target and property path
+ var target = UndoReflectionHelper.GetTarget(undoMod);
+ if (target == null)
+ continue;
+
+ var propertyPath = UndoReflectionHelper.GetPropertyPath(undoMod);
+ if (string.IsNullOrEmpty(propertyPath))
+ continue;
+
+ // Filter out Unity internal properties
+ if (PropertyFormatter.IsInternalProperty(propertyPath))
+ {
+ continue;
+ }
+
+ // Generate stable unique key
+ string globalId = GlobalIdHelper.ToGlobalIdString(target);
+ if (string.IsNullOrEmpty(globalId))
+ continue;
+
+ string uniqueKey = $"{globalId}:{propertyPath}";
+
+ // Get the current value (not the path)
+ var currentValue = UndoReflectionHelper.GetCurrentValue(undoMod);
+
+ // Check if we already have a pending change for this property
+ if (_pendingChanges.TryGetValue(uniqueKey, out var pending))
+ {
+ // Update existing pending change
+ // Note: Must reassign to dictionary since PendingPropertyChange is a struct
+ pending.EndValue = PropertyFormatter.FormatPropertyValue(currentValue);
+ pending.ChangeCount++;
+ pending.LastUpdateMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+ _pendingChanges[uniqueKey] = pending;
+ }
+ else
+ {
+ // Enforce cache limit to prevent unbounded growth
+ if (_pendingChanges.Count >= MaxPendingEntries)
+ {
+ // Force flush before adding new entry
+ FlushPendingChanges();
+ }
+
+ // Create new pending change (use object pool if available)
+ var change = AcquirePendingChange();
+ change.GlobalId = globalId;
+ change.TargetName = target.name;
+ change.ComponentType = target.GetType().Name;
+ change.PropertyPath = propertyPath;
+ // Record the start value from the previous value reported by Undo system
+ var prev = UndoReflectionHelper.GetPreviousValue(undoMod);
+ change.StartValue = PropertyFormatter.FormatPropertyValue(prev);
+ change.EndValue = PropertyFormatter.FormatPropertyValue(currentValue);
+ change.PropertyType = PropertyFormatter.GetPropertyTypeName(currentValue);
+ change.ChangeCount = 1;
+ change.LastUpdateMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+
+ _pendingChanges[uniqueKey] = change;
+ }
+ }
+
+ return modifications;
+ }
+
+ ///
+ /// Flushes all pending property changes that have exceeded the debounce window.
+ /// Called periodically via EditorApplication.update.
+ ///
+ private static void FlushPendingChanges()
+ {
+ if (_pendingChanges.Count == 0)
+ return;
+
+ long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+
+ foreach (var kvp in _pendingChanges)
+ {
+ // Check if entry has expired (no updates for DebounceWindowMs)
+ if (nowMs - kvp.Value.LastUpdateMs >= DebounceWindowMs)
+ {
+ // Record the PropertyModified event
+ RecordPropertyModifiedEvent(kvp.Value);
+
+ // Return to object pool
+ ReturnPendingChange(kvp.Value);
+
+ // Mark for removal
+ _removedKeys.Add(kvp.Key);
+ }
+ }
+
+ // Batch remove expired entries
+ foreach (var key in _removedKeys)
+ {
+ _pendingChanges.Remove(key);
+ }
+ _removedKeys.Clear();
+ }
+
+ ///
+ /// Records a PropertyModified event to the ActionTrace EventStore.
+ ///
+ private static void RecordPropertyModifiedEvent(in PendingPropertyChange change)
+ {
+ var payload = new Dictionary
+ {
+ ["target_name"] = change.TargetName,
+ ["component_type"] = change.ComponentType,
+ ["property_path"] = change.PropertyPath,
+ ["start_value"] = change.StartValue,
+ ["end_value"] = change.EndValue,
+ ["value_type"] = change.PropertyType,
+ ["change_count"] = change.ChangeCount
+ };
+
+ var evt = new EditorEvent(
+ sequence: 0, // Will be assigned by EventStore.Record()
+ timestampUnixMs: change.LastUpdateMs,
+ type: EventTypes.PropertyModified,
+ targetId: change.GlobalId,
+ payload: payload
+ );
+
+ EventStore.Record(evt);
+ }
+
+ ///
+ /// Acquires a PendingPropertyChange from the object pool.
+ /// Creates a new instance if pool is empty.
+ ///
+ private static PendingPropertyChange AcquirePendingChange()
+ {
+ if (_objectPool.Count > 0)
+ {
+ var change = _objectPool.Pop();
+ // Reset is handled by ReturnPendingChange before pushing back
+ return change;
+ }
+ return new PendingPropertyChange();
+ }
+
+ ///
+ /// Returns a PendingPropertyChange to the object pool after clearing its data.
+ ///
+ private static void ReturnPendingChange(in PendingPropertyChange change)
+ {
+ // Create a copy to clear (structs are value types)
+ var cleared = change;
+ cleared.Reset();
+ _objectPool.Push(cleared);
+ }
+
+ ///
+ /// Forces an immediate flush of all pending changes.
+ /// Useful for shutdown or before critical operations.
+ ///
+ public static void ForceFlush()
+ {
+ FlushPendingChanges();
+ }
+
+ ///
+ /// Gets the current count of pending changes.
+ /// Useful for debugging and monitoring.
+ ///
+ public static int PendingCount => _pendingChanges.Count;
+
+ ///
+ /// Clears all pending changes without recording them.
+ /// Useful for testing or error recovery.
+ ///
+ public static void ClearPending()
+ {
+ foreach (var kvp in _pendingChanges)
+ {
+ ReturnPendingChange(kvp.Value);
+ }
+ _pendingChanges.Clear();
+ }
+ }
+
+ ///
+ /// Represents a property change that is pending debounce.
+ /// Uses a struct to reduce GC pressure (stored on stack when possible).
+ ///
+ public struct PendingPropertyChange
+ {
+ public string GlobalId; // Cross-session stable object ID
+ public string TargetName; // Object name (e.g., "Main Camera")
+ public string ComponentType; // Component type (e.g., "Light")
+ public string PropertyPath; // Serialized property path (e.g., "m_Intensity")
+ public string StartValue; // JSON formatted start value
+ public string EndValue; // JSON formatted end value
+ public string PropertyType; // Type name of the property value
+ public int ChangeCount; // Number of changes merged (for Slider drag)
+ public long LastUpdateMs; // Last update timestamp for debouncing
+
+ ///
+ /// Resets all fields to default values.
+ /// Called before returning the struct to the object pool.
+ ///
+ public void Reset()
+ {
+ GlobalId = null;
+ TargetName = null;
+ ComponentType = null;
+ PropertyPath = null;
+ StartValue = null;
+ EndValue = null;
+ PropertyType = null;
+ ChangeCount = 0;
+ LastUpdateMs = 0;
+ }
+ }
+}
diff --git a/MCPForUnity/Editor/ActionTrace/Capture/SamplingMiddleware.cs b/MCPForUnity/Editor/ActionTrace/Capture/SamplingMiddleware.cs
new file mode 100644
index 000000000..c92367e6b
--- /dev/null
+++ b/MCPForUnity/Editor/ActionTrace/Capture/SamplingMiddleware.cs
@@ -0,0 +1,435 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Linq;
+using MCPForUnity.Editor.ActionTrace.Core;
+using UnityEditor;
+
+namespace MCPForUnity.Editor.ActionTrace.Capture
+{
+ ///
+ /// Smart sampling middleware to prevent event floods in high-frequency scenarios.
+ ///
+ /// Protects the ActionTrace from event storms (e.g., rapid Slider dragging,
+ /// continuous Hierarchy changes) by applying configurable sampling strategies.
+ ///
+ /// Sampling modes:
+ /// - None: No filtering, record all events
+ /// - Throttle: Only record the first event within the window
+ /// - Debounce: Only record the last event within the window
+ /// - DebounceByKey: Only record the last event per unique key within the window
+ ///
+ /// Reuses existing infrastructure:
+ /// - GlobalIdHelper.ToGlobalIdString() for stable keys
+ /// - EditorEvent payload for event metadata
+ ///
+ [InitializeOnLoad]
+ public static class SamplingMiddleware
+ {
+ // Configuration
+ private const int MaxSampleCache = 128; // Max pending samples before forced cleanup
+ private const long CleanupAgeMs = 2000; // Cleanup samples older than 2 seconds
+ private const long FlushCheckIntervalMs = 200; // Check for expired debounce samples every 200ms
+
+ // State
+ // Thread-safe dictionary to prevent race conditions in multi-threaded scenarios
+ private static readonly ConcurrentDictionary _pendingSamples = new();
+ private static long _lastCleanupTime;
+ private static long _lastFlushCheckTime;
+
+ ///
+ /// Initializes the sampling middleware and schedules periodic flush checks.
+ ///
+ static SamplingMiddleware()
+ {
+ ScheduleFlushCheck();
+ }
+
+ ///
+ /// Schedules a periodic flush check using EditorApplication.update.
+ /// This ensures Debounce modes emit trailing events after their windows expire.
+ /// Using update instead of delayCall to avoid infinite recursion.
+ ///
+ private static void ScheduleFlushCheck()
+ {
+ // Use EditorApplication.update instead of delayCall to avoid infinite recursion
+ // This ensures the callback is properly cleaned up on domain reload
+ EditorApplication.update -= FlushExpiredDebounceSamples;
+ EditorApplication.update += FlushExpiredDebounceSamples;
+ }
+
+ ///
+ /// Flushes debounce samples whose windows have expired.
+ /// This ensures Debounce/DebounceByKey modes emit the trailing event.
+ ///
+ private static void FlushExpiredDebounceSamples()
+ {
+ long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+
+ // Only check periodically to avoid performance impact
+ if (nowMs - _lastFlushCheckTime < FlushCheckIntervalMs)
+ return;
+
+ _lastFlushCheckTime = nowMs;
+
+ var toRecord = new List();
+
+ // Directly remove expired entries without intermediate list
+ foreach (var kvp in _pendingSamples)
+ {
+ // Check if this key has a debounce strategy configured
+ if (SamplingConfig.Strategies.TryGetValue(kvp.Value.Event.Type, out var strategy))
+ {
+ // Only process Debounce/DebounceByKey modes
+ if (strategy.Mode == SamplingMode.Debounce || strategy.Mode == SamplingMode.DebounceByKey)
+ {
+ // If window has expired, this sample should be recorded
+ if (nowMs - kvp.Value.TimestampMs > strategy.WindowMs)
+ {
+ toRecord.Add(kvp.Value);
+ // Remove immediately while iterating (TryRemove is safe)
+ _pendingSamples.TryRemove(kvp.Key, out _);
+ }
+ }
+ }
+ }
+
+ // Record the trailing events
+ foreach (var sample in toRecord)
+ {
+ // Record directly to EventStore without going through ShouldRecord again
+ EventStore.Record(sample.Event);
+ }
+ }
+
+ ///
+ /// Determines whether an event should be recorded based on configured sampling strategies.
+ /// Returns true if the event should be recorded, false if it should be filtered out.
+ ///
+ /// This method is called by event emitters before recording to EventStore.
+ /// Implements a three-stage filtering pipeline:
+ /// 1. Blacklist (EventFilter) - filters system junk
+ /// 2. Sampling strategy - merges duplicate events
+ /// 3. Cache management - prevents unbounded growth
+ ///
+ public static bool ShouldRecord(EditorEvent evt)
+ {
+ if (evt == null)
+ return false;
+
+ // ========== Stage 1: Blacklist Filtering (L1) ==========
+ // Check if this event's target is known junk before any other processing
+ if (evt.Type == EventTypes.AssetImported ||
+ evt.Type == EventTypes.AssetMoved ||
+ evt.Type == EventTypes.AssetDeleted)
+ {
+ // For asset events, check the path (stored in TargetId or payload)
+ string assetPath = evt.TargetId;
+ if (string.IsNullOrEmpty(assetPath) && evt.Payload.TryGetValue("path", out var pathVal))
+ {
+ assetPath = pathVal?.ToString();
+ }
+
+ if (!string.IsNullOrEmpty(assetPath) && !EventFilter.ShouldTrackAsset(assetPath))
+ {
+ return false; // Filtered by blacklist
+ }
+ }
+
+ // ========== Stage 2: Sampling Strategy Check (L2) ==========
+ // No sampling strategy configured - record all events
+ if (!SamplingConfig.Strategies.TryGetValue(evt.Type, out var strategy))
+ return true;
+
+ // Strategy is None - record all events of this type
+ if (strategy.Mode == SamplingMode.None)
+ return true;
+
+ // Generate the sampling key based on mode
+ string key = GenerateSamplingKey(evt, strategy.Mode);
+
+ if (string.IsNullOrEmpty(key))
+ return true;
+
+ long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+
+ // Periodic cleanup of expired samples (runs every ~1 second)
+ if (nowMs - _lastCleanupTime > 1000)
+ {
+ CleanupExpiredSamples(nowMs);
+ _lastCleanupTime = nowMs;
+ }
+
+ // Check if we have a pending sample for this key
+ if (_pendingSamples.TryGetValue(key, out var pending))
+ {
+ // Sample is still within the window
+ if (nowMs - pending.TimestampMs <= strategy.WindowMs)
+ {
+ switch (strategy.Mode)
+ {
+ case SamplingMode.Throttle:
+ // Throttle: Drop all events after the first in the window
+ return false;
+
+ case SamplingMode.Debounce:
+ case SamplingMode.DebounceByKey:
+ // Debounce: Keep only the last event in the window
+ // Note: Must update the dictionary entry since PendingSample is a struct
+ _pendingSamples[key] = new PendingSample
+ {
+ Event = evt,
+ TimestampMs = nowMs
+ };
+ return false;
+ }
+ }
+
+ // Window expired - remove old entry
+ _pendingSamples.TryRemove(key, out _);
+ }
+
+ // Enforce cache limit to prevent unbounded growth
+ if (_pendingSamples.Count >= MaxSampleCache)
+ {
+ CleanupExpiredSamples(nowMs);
+
+ // If still over limit after cleanup, force remove oldest entry
+ if (_pendingSamples.Count >= MaxSampleCache)
+ {
+ // Manual loop to find oldest entry (avoid LINQ allocation in hot path)
+ string oldestKey = null;
+ long oldestTimestamp = long.MaxValue;
+ foreach (var kvp in _pendingSamples)
+ {
+ if (kvp.Value.TimestampMs < oldestTimestamp)
+ {
+ oldestTimestamp = kvp.Value.TimestampMs;
+ oldestKey = kvp.Key;
+ }
+ }
+ if (!string.IsNullOrEmpty(oldestKey))
+ {
+ _pendingSamples.TryRemove(oldestKey, out _);
+ }
+ }
+ }
+
+ // Add new pending sample
+ _pendingSamples[key] = new PendingSample
+ {
+ Event = evt,
+ TimestampMs = nowMs
+ };
+
+ // For Debounce modes, don't record immediately - wait for window to expire
+ // This prevents duplicate recording: first event here, trailing event in FlushExpiredDebounceSamples
+ if (strategy.Mode == SamplingMode.Debounce || strategy.Mode == SamplingMode.DebounceByKey)
+ return false;
+
+ // For Throttle mode, record the first event immediately
+ return true;
+ }
+
+ ///
+ /// Generates the sampling key based on the sampling mode.
+ /// - Throttle/Debounce: Key by event type only
+ /// - DebounceByKey: Key by event type + target (GlobalId)
+ ///
+ private static string GenerateSamplingKey(EditorEvent evt, SamplingMode mode)
+ {
+ // For DebounceByKey, include TargetId to distinguish different objects
+ if (mode == SamplingMode.DebounceByKey)
+ {
+ return $"{evt.Type}:{evt.TargetId}";
+ }
+
+ // For Throttle and Debounce, key by type only
+ return evt.Type;
+ }
+
+ ///
+ /// Removes expired samples from the cache.
+ /// Samples older than CleanupAgeMs are removed.
+ ///
+ private static void CleanupExpiredSamples(long nowMs)
+ {
+ // Directly remove expired samples without intermediate list
+ foreach (var kvp in _pendingSamples)
+ {
+ if (nowMs - kvp.Value.TimestampMs > CleanupAgeMs)
+ {
+ _pendingSamples.TryRemove(kvp.Key, out _);
+ }
+ }
+ }
+
+ ///
+ /// Forces an immediate flush of all pending samples.
+ /// Returns the events that were pending (useful for shutdown).
+ ///
+ public static List FlushPending()
+ {
+ // Manual loop instead of LINQ Select to avoid allocation
+ var result = new List(_pendingSamples.Count);
+ foreach (var kvp in _pendingSamples)
+ {
+ result.Add(kvp.Value.Event);
+ }
+ _pendingSamples.Clear();
+ return result;
+ }
+
+ ///
+ /// Gets the current count of pending samples.
+ /// Useful for debugging and monitoring.
+ ///
+ public static int PendingCount => _pendingSamples.Count;
+
+ ///
+ /// Diagnostic helper: returns a snapshot of pending sampling keys.
+ /// Safe to call from editor threads; best-effort snapshot.
+ ///
+ public static IReadOnlyList GetPendingKeysSnapshot()
+ {
+ return _pendingSamples.Keys.ToList();
+ }
+
+ ///
+ /// Clears all pending samples without recording them.
+ /// Useful for testing or error recovery.
+ ///
+ public static void ClearPending()
+ {
+ _pendingSamples.Clear();
+ }
+ }
+
+ ///
+ /// Configurable sampling strategy for a specific event type.
+ ///
+ public class SamplingStrategy
+ {
+ ///
+ /// The sampling mode to apply.
+ ///
+ public SamplingMode Mode { get; set; }
+
+ ///
+ /// Time window in milliseconds.
+ /// - Throttle: Only first event within this window is recorded
+ /// - Debounce/DebounceByKey: Only last event within this window is recorded
+ ///
+ public long WindowMs { get; set; }
+
+ public SamplingStrategy(SamplingMode mode = SamplingMode.None, long windowMs = 1000)
+ {
+ Mode = mode;
+ WindowMs = windowMs;
+ }
+ }
+
+ ///
+ /// Sampling mode determines how events are filtered.
+ ///
+ public enum SamplingMode
+ {
+ /// No filtering - record all events
+ None,
+
+ /// Throttle - only record the first event within the window
+ Throttle,
+
+ /// Debounce - only record the last event within the window (per type)
+ Debounce,
+
+ /// DebounceByKey - only record the last event per key within the window
+ DebounceByKey
+ }
+
+ ///
+ /// Static configuration for sampling strategies.
+ /// Event types can be registered with their desired sampling behavior.
+ ///
+ public static class SamplingConfig
+ {
+ ///
+ /// Default sampling strategies for common event types.
+ /// Configured to prevent event floods while preserving important data.
+ ///
+ public static readonly Dictionary Strategies = new()
+ {
+ // Hierarchy changes: Throttle to 1 event per second
+ {
+ EventTypes.HierarchyChanged,
+ new SamplingStrategy(SamplingMode.Throttle, 1000)
+ },
+
+ // PropertyModified handling removed here to avoid double-debounce when
+ // PropertyChangeTracker already implements a dedicated debounce window.
+ // If desired, SamplingConfig.SetStrategy(EventTypes.PropertyModified, ...) can
+ // be used at runtime to re-enable middleware-level sampling.
+
+ // Component/GameObject events: No sampling (always record)
+ // ComponentAdded, ComponentRemoved, GameObjectCreated, GameObjectDestroyed
+ // are intentionally not in this dictionary, so they default to None
+
+ // Play mode changes: No sampling (record all)
+ // PlayModeChanged is not in this dictionary
+
+ // Scene events: No sampling (record all)
+ // SceneSaving, SceneSaved, SceneOpened, NewSceneCreated are not in this dictionary
+
+ // Build events: No sampling (record all)
+ // BuildStarted, BuildCompleted, BuildFailed are not in this dictionary
+ };
+
+ ///
+ /// Adds or updates a sampling strategy for an event type.
+ ///
+ public static void SetStrategy(string eventType, SamplingMode mode, long windowMs = 1000)
+ {
+ Strategies[eventType] = new SamplingStrategy(mode, windowMs);
+ }
+
+ ///
+ /// Removes the sampling strategy for an event type (reverts to None).
+ ///
+ public static void RemoveStrategy(string eventType)
+ {
+ Strategies.Remove(eventType);
+ }
+
+ ///
+ /// Gets the sampling strategy for an event type, or null if not configured.
+ ///
+ public static SamplingStrategy GetStrategy(string eventType)
+ {
+ return Strategies.TryGetValue(eventType, out var strategy) ? strategy : null;
+ }
+
+ ///
+ /// Checks if an event type has a sampling strategy configured.
+ ///
+ public static bool HasStrategy(string eventType)
+ {
+ return Strategies.ContainsKey(eventType);
+ }
+ }
+
+ ///
+ /// Represents a pending sample that is being filtered.
+ ///
+ public struct PendingSample
+ {
+ ///
+ /// The event being held for potential recording.
+ ///
+ public EditorEvent Event;
+
+ ///
+ /// Timestamp when this sample was last updated.
+ ///
+ public long TimestampMs;
+ }
+}
diff --git a/MCPForUnity/Editor/ActionTrace/Capture/SelectionPropertyTracker.cs b/MCPForUnity/Editor/ActionTrace/Capture/SelectionPropertyTracker.cs
new file mode 100644
index 000000000..dcb284986
--- /dev/null
+++ b/MCPForUnity/Editor/ActionTrace/Capture/SelectionPropertyTracker.cs
@@ -0,0 +1,213 @@
+using System;
+using System.Collections.Generic;
+using UnityEditor;
+using UnityEngine;
+using MCPForUnity.Editor.ActionTrace.Core;
+using MCPForUnity.Editor.Helpers;
+using MCPForUnity.Editor.ActionTrace.Helpers;
+
+namespace MCPForUnity.Editor.ActionTrace.Capture
+{
+ ///
+ /// Tracks property modifications made to the currently selected object.
+ ///
+ /// Combines Selection.selectionChanged with Undo.postprocessModifications
+ /// to provide rich context about which object's properties are being modified.
+ ///
+ /// Key features:
+ /// - Detects if property modification targets the currently selected object
+ /// - Records SelectionPropertyModified events with selection context
+ /// - Reuses existing helpers (GlobalIdHelper, UnityJsonSerializer)
+ /// - Lightweight event-based design (no polling)
+ ///
+ [InitializeOnLoad]
+ public static class SelectionPropertyTracker
+ {
+ // Current selection state
+ private static string _currentSelectionGlobalId;
+ private static string _currentSelectionName;
+ private static string _currentSelectionType;
+ private static string _currentSelectionPath;
+
+ static SelectionPropertyTracker()
+ {
+ // Initialize with current selection
+ UpdateSelectionState();
+
+ // Monitor selection changes
+ Selection.selectionChanged += OnSelectionChanged;
+
+ // Monitor property modifications
+ Undo.postprocessModifications += OnPropertyModified;
+
+ McpLog.Debug("[SelectionPropertyTracker] Initialized");
+ }
+
+ ///
+ /// Updates the cached selection state when selection changes.
+ ///
+ private static void OnSelectionChanged()
+ {
+ UpdateSelectionState();
+ }
+
+ ///
+ /// Updates the cached selection state from current Selection.activeObject.
+ ///
+ private static void UpdateSelectionState()
+ {
+ var activeObject = Selection.activeObject;
+ if (activeObject == null)
+ {
+ _currentSelectionGlobalId = null;
+ _currentSelectionName = null;
+ _currentSelectionType = null;
+ _currentSelectionPath = null;
+ return;
+ }
+
+ _currentSelectionGlobalId = GlobalIdHelper.ToGlobalIdString(activeObject);
+ _currentSelectionName = activeObject.name;
+ _currentSelectionType = activeObject.GetType().Name;
+
+ // Get path for GameObject/Component selections
+ if (activeObject is GameObject go)
+ {
+ _currentSelectionPath = GetGameObjectPath(go);
+ }
+ else if (activeObject is Component comp)
+ {
+ _currentSelectionPath = GetGameObjectPath(comp.gameObject);
+ }
+ else
+ {
+ _currentSelectionPath = AssetDatabase.GetAssetPath(activeObject);
+ }
+ }
+
+ ///
+ /// Called by Unity when properties are modified via Undo system.
+ /// Checks if the modification targets the currently selected object.
+ ///
+ private static UndoPropertyModification[] OnPropertyModified(UndoPropertyModification[] modifications)
+ {
+ if (modifications == null || modifications.Length == 0)
+ return modifications;
+
+ McpLog.Debug($"[SelectionPropertyTracker] OnPropertyModified: {modifications.Length} mods, selectionId={_currentSelectionGlobalId}");
+
+ // Skip if no valid selection
+ if (string.IsNullOrEmpty(_currentSelectionGlobalId))
+ return modifications;
+
+ foreach (var undoMod in modifications)
+ {
+ var target = UndoReflectionHelper.GetTarget(undoMod);
+ if (target == null)
+ {
+ continue;
+ }
+
+ // Check if this modification targets the currently selected object or its components
+ string targetGlobalId = GlobalIdHelper.ToGlobalIdString(target);
+ bool isMatch = IsTargetMatchSelection(target, targetGlobalId);
+ // McpLog.Debug($"[SelectionPropertyTracker] targetId={targetGlobalId}, selectionId={_currentSelectionGlobalId}, match={isMatch}");
+ if (!isMatch)
+ continue;
+
+ var propertyPath = UndoReflectionHelper.GetPropertyPath(undoMod);
+ if (string.IsNullOrEmpty(propertyPath))
+ continue;
+
+ // Filter out Unity internal properties
+ if (PropertyFormatter.IsInternalProperty(propertyPath))
+ continue;
+
+ // Record the SelectionPropertyModified event
+ // McpLog.Debug($"[SelectionPropertyTracker] MATCH! Recording event for {target.name}.{propertyPath}");
+ RecordSelectionPropertyModified(undoMod, target, targetGlobalId, propertyPath);
+ }
+
+ return modifications;
+ }
+
+ ///
+ /// Records a SelectionPropertyModified event to the ActionTrace EventStore.
+ ///
+ private static void RecordSelectionPropertyModified(UndoPropertyModification undoMod, UnityEngine.Object target, string targetGlobalId, string propertyPath)
+ {
+ var currentValue = UndoReflectionHelper.GetCurrentValue(undoMod);
+ var prevValue = UndoReflectionHelper.GetPreviousValue(undoMod);
+
+ var payload = new Dictionary
+ {
+ ["target_name"] = target.name,
+ ["component_type"] = target.GetType().Name,
+ ["property_path"] = propertyPath,
+ ["start_value"] = PropertyFormatter.FormatPropertyValue(prevValue),
+ ["end_value"] = PropertyFormatter.FormatPropertyValue(currentValue),
+ ["value_type"] = PropertyFormatter.GetPropertyTypeName(currentValue),
+ ["selection_context"] = new Dictionary
+ {
+ ["selection_id"] = _currentSelectionGlobalId,
+ ["selection_name"] = _currentSelectionName,
+ ["selection_type"] = _currentSelectionType,
+ ["selection_path"] = _currentSelectionPath ?? string.Empty
+ }
+ };
+
+ var evt = new EditorEvent(
+ sequence: 0,
+ timestampUnixMs: DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ type: EventTypes.SelectionPropertyModified,
+ targetId: targetGlobalId,
+ payload: payload
+ );
+
+ EventStore.Record(evt);
+ }
+
+
+ ///
+ /// Checks if the modified target matches the current selection.
+ /// Handles both direct GameObject matches and Component-on-selected-GameObject matches.
+ ///
+ private static bool IsTargetMatchSelection(UnityEngine.Object target, string targetGlobalId)
+ {
+ // Direct match
+ if (targetGlobalId == _currentSelectionGlobalId)
+ return true;
+
+ // If target is a Component, check if its owner GameObject matches the selection
+ if (target is Component comp)
+ {
+ string gameObjectId = GlobalIdHelper.ToGlobalIdString(comp.gameObject);
+ if (gameObjectId == _currentSelectionGlobalId)
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Gets the full Hierarchy path for a GameObject.
+ /// Example: "Level1/Player/Arm/Hand"
+ ///
+ private static string GetGameObjectPath(GameObject obj)
+ {
+ if (obj == null)
+ return "Unknown";
+
+ var path = obj.name;
+ var parent = obj.transform.parent;
+
+ while (parent != null)
+ {
+ path = $"{parent.name}/{path}";
+ parent = parent.parent;
+ }
+
+ return path;
+ }
+ }
+}
diff --git a/MCPForUnity/Editor/ActionTrace/Capture/UndoGroupManager.cs b/MCPForUnity/Editor/ActionTrace/Capture/UndoGroupManager.cs
new file mode 100644
index 000000000..de564aa35
--- /dev/null
+++ b/MCPForUnity/Editor/ActionTrace/Capture/UndoGroupManager.cs
@@ -0,0 +1,188 @@
+using System;
+using UnityEditor;
+using MCPForUnity.Editor.ActionTrace.Helpers;
+using MCPForUnity.Editor.Helpers;
+
+namespace MCPForUnity.Editor.ActionTrace.Capture
+{
+ ///
+ /// Manages Unity Undo grouping for AI tool calls.
+ ///
+ /// Purpose:
+ /// - Groups multiple Undo operations into a single logical transaction
+ /// - Enables one Ctrl+Z to undo an entire AI tool call
+ /// - Works with TransactionAggregator to provide atomic operation semantics
+ ///
+ /// Usage (from ActionTrace-enhancements.md line 320-336):
+ /// UndoGroupManager.BeginToolCall("manage_gameobject", "abc123");
+ /// // ... perform operations ...
+ /// UndoGroupManager.EndToolCall();
+ ///
+ /// Integration with add_ActionTrace_note:
+ /// - When AI adds a note with is_transaction_end=true,
+ /// automatically collapses Undo operations since BeginToolCall
+ ///
+ /// Architecture notes:
+ /// - This is an optional enhancement for better UX
+ /// - Does not affect ActionTrace event recording
+ /// - Independent of OperationContext (tracks Undo state, not tool context)
+ ///
+ public static class UndoGroupManager
+ {
+ // State tracking
+ private static string _currentToolName;
+ private static string _currentToolCallId;
+ private static int _currentUndoGroupStart = -1;
+ private static bool _isInToolCall = false;
+
+ ///
+ /// Starts a new Undo group for a tool call.
+ ///
+ /// Call this at the beginning of an AI tool operation.
+ /// All subsequent Undo operations will be grouped under this name.
+ ///
+ /// Parameters:
+ /// toolName: Name of the tool (e.g., "manage_gameobject")
+ /// toolCallId: Unique identifier for this tool call (UUID)
+ ///
+ /// Example:
+ /// UndoGroupManager.BeginToolCall("manage_gameobject", "abc-123-def");
+ ///
+ public static void BeginToolCall(string toolName, string toolCallId)
+ {
+ if (string.IsNullOrEmpty(toolName))
+ {
+ McpLog.Warn("[UndoGroupManager] BeginToolCall called with null toolName");
+ toolName = "AI Operation";
+ }
+
+ // Set the current Undo group name
+ // This name will appear in the Undo history (e.g., "Ctrl+Z AI: Create Player")
+ Undo.SetCurrentGroupName($"AI: {ActionTraceHelper.FormatToolName(toolName)}");
+
+ // Record the group start position for later collapsing
+ _currentUndoGroupStart = Undo.GetCurrentGroup();
+ _currentToolName = toolName;
+ _currentToolCallId = toolCallId;
+ _isInToolCall = true;
+
+ McpLog.Info($"[UndoGroupManager] BeginToolCall: {toolName} (group {_currentUndoGroupStart})");
+ }
+
+ ///
+ /// Ends the current Undo group and collapses all operations.
+ ///
+ /// Call this at the end of an AI tool operation.
+ /// All Undo operations since BeginToolCall will be merged into one.
+ ///
+ /// Example:
+ /// UndoGroupManager.EndToolCall();
+ ///
+ /// After this, user can press Ctrl+Z once to undo the entire tool call.
+ ///
+ public static void EndToolCall()
+ {
+ if (!_isInToolCall)
+ {
+ McpLog.Warn("[UndoGroupManager] EndToolCall called without matching BeginToolCall");
+ return;
+ }
+
+ if (_currentUndoGroupStart >= 0)
+ {
+ // Collapse all Undo operations since BeginToolCall into one group
+ Undo.CollapseUndoOperations(_currentUndoGroupStart);
+
+ McpLog.Info($"[UndoGroupManager] EndToolCall: {_currentToolName} (collapsed from group {_currentUndoGroupStart})");
+ }
+
+ // Reset state
+ _currentToolName = null;
+ _currentToolCallId = null;
+ _currentUndoGroupStart = -1;
+ _isInToolCall = false;
+ }
+
+ ///
+ /// Checks if currently in a tool call.
+ ///
+ public static bool IsInToolCall => _isInToolCall;
+
+ ///
+ /// Gets the current tool name (if in a tool call).
+ /// Returns null if not in a tool call.
+ ///
+ public static string CurrentToolName => _currentToolName;
+
+ ///
+ /// Gets the current tool call ID (if in a tool call).
+ /// Returns null if not in a tool call.
+ ///
+ public static string CurrentToolCallId => _currentToolCallId;
+
+ ///
+ /// Gets the current Undo group start position.
+ /// Returns -1 if not in a tool call.
+ ///
+ public static int CurrentUndoGroupStart => _currentUndoGroupStart;
+
+ ///
+ /// Clears the current tool call state without collapsing.
+ ///
+ /// Use this for error recovery when a tool call fails partway through.
+ /// Does NOT collapse Undo operations (unlike EndToolCall).
+ ///
+ public static void AbortToolCall()
+ {
+ if (!_isInToolCall)
+ return;
+
+ McpLog.Warn($"[UndoGroupManager] AbortToolCall: {_currentToolName} (group {_currentUndoGroupStart})");
+
+ // Reset state without collapsing
+ _currentToolName = null;
+ _currentToolCallId = null;
+ _currentUndoGroupStart = -1;
+ _isInToolCall = false;
+ }
+
+ ///
+ /// Integration with add_ActionTrace_note.
+ ///
+ /// When AI adds a note with is_transaction_end=true,
+ /// automatically end the current Undo group.
+ ///
+ /// This allows the AI to mark completion of a logical transaction.
+ ///
+ /// Parameters:
+ /// note: The note text (will be used as Undo group name if in tool call)
+ /// isTransactionEnd: If true, calls EndToolCall()
+ ///
+ /// Returns:
+ /// The Undo group name that was set (or current group name if not ending)
+ ///
+ public static string HandleActionTraceNote(string note, bool isTransactionEnd)
+ {
+ string groupName;
+
+ if (_isInToolCall)
+ {
+ // Use the AI note as the final Undo group name
+ groupName = $"AI: {note}";
+ Undo.SetCurrentGroupName(groupName);
+
+ if (isTransactionEnd)
+ {
+ EndToolCall();
+ }
+
+ return groupName;
+ }
+
+ // Not in a tool call - just set the Undo name
+ groupName = $"AI: {note}";
+ Undo.SetCurrentGroupName(groupName);
+ return groupName;
+ }
+ }
+}
diff --git a/MCPForUnity/Editor/ActionTrace/Capture/UnityEventHooks.cs b/MCPForUnity/Editor/ActionTrace/Capture/UnityEventHooks.cs
new file mode 100644
index 000000000..01f673409
--- /dev/null
+++ b/MCPForUnity/Editor/ActionTrace/Capture/UnityEventHooks.cs
@@ -0,0 +1,536 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using MCPForUnity.Editor.ActionTrace.Core;
+using MCPForUnity.Editor.ActionTrace.Helpers;
+using MCPForUnity.Editor.Helpers;
+using UnityEditor;
+using UnityEditor.SceneManagement;
+using UnityEngine;
+using UnityEngine.SceneManagement;
+
+namespace MCPForUnity.Editor.ActionTrace.Capture
+{
+ ///
+ /// Captures Unity editor events and records them to the EventStore.
+ /// Uses debouncing to avoid spamming for rapid successive changes.
+ ///
+ /// Hook Coverage:
+ /// - Component events: ComponentAdded
+ /// - GameObject events: GameObjectCreated, GameObjectDestroyed
+ /// - Hierarchy events: HierarchyChanged
+ /// - Selection events: SelectionChanged
+ /// - Play mode events: PlayModeChanged
+ /// - Scene events: SceneSaving, SceneSaved, SceneOpened, NewSceneCreated
+ /// - Script events: ScriptCompiled, ScriptCompilationFailed
+ /// - Build events: BuildStarted, BuildCompleted, BuildFailed
+ ///
+ /// Note: Asset events are handled in AssetChangePostprocessor.cs
+ ///
+ [InitializeOnLoad]
+ public static class UnityEventHooks
+ {
+ private static DateTime _lastHierarchyChange;
+ private static readonly object _lock = new();
+
+ // Track compilation state
+ private static DateTime _compileStartTime;
+ private static bool _isCompiling;
+
+ // Track build state
+ private static string _currentBuildPlatform;
+ private static DateTime _buildStartTime;
+
+ static UnityEventHooks()
+ {
+ // ========== GameObject/Component Events ==========
+ ObjectFactory.componentWasAdded += OnComponentAdded;
+
+ // Monitor hierarchy changes (with debouncing)
+ EditorApplication.hierarchyChanged += OnHierarchyChanged;
+
+ // ========== Selection Events ==========
+ Selection.selectionChanged += OnSelectionChanged;
+
+ // ========== Play Mode Events ==========
+ EditorApplication.playModeStateChanged += OnPlayModeStateChanged;
+
+ // ========== Scene Events ==========
+ EditorSceneManager.sceneSaving += OnSceneSaving;
+ EditorSceneManager.sceneSaved += OnSceneSaved;
+ EditorSceneManager.sceneOpened += OnSceneOpened;
+ EditorSceneManager.sceneLoaded += OnSceneLoaded;
+ EditorSceneManager.sceneUnloaded += OnSceneUnloaded;
+ EditorSceneManager.newSceneCreated += OnNewSceneCreated;
+
+ // ========== Build Events ==========
+ BuildPlayerWindow.RegisterBuildPlayerHandler(BuildPlayerHandler);
+
+ // Track changes in edit mode to detect GameObject creation/destruction
+ EditorApplication.update += OnUpdate;
+
+ // Initialize GameObject tracking after domain reload
+ EditorApplication.delayCall += () => GameObjectTrackingHelper.InitializeTracking();
+ }
+
+ #region GameObject/Component Events
+
+ ///
+ /// Called when a component is added to a GameObject.
+ ///
+ private static void OnComponentAdded(Component component)
+ {
+ if (component == null) return;
+
+ string globalId = GlobalIdHelper.ToGlobalIdString(component);
+
+ var payload = new Dictionary
+ {
+ ["component_type"] = component.GetType().Name,
+ ["game_object"] = component.gameObject?.name ?? "Unknown"
+ };
+
+ RecordEvent(EventTypes.ComponentAdded, globalId, payload);
+ }
+
+ ///
+ /// Update loop for detecting GameObject creation and destruction.
+ ///
+ private static void OnUpdate()
+ {
+ // Only track in edit mode, not during play mode
+ if (EditorApplication.isPlayingOrWillChangePlaymode)
+ return;
+
+ // Track script compilation
+ TrackScriptCompilation();
+
+ // Detect GameObject changes
+ TrackGameObjectChanges();
+ }
+
+ ///
+ /// Track GameObject creation and destruction using GameObjectTrackingHelper.
+ ///
+ private static void TrackGameObjectChanges()
+ {
+ var changes = GameObjectTrackingHelper.DetectChanges();
+
+ foreach (var change in changes)
+ {
+ if (change.isNew)
+ {
+ GameObject go = change.obj;
+ string globalId = GlobalIdHelper.ToGlobalIdString(go);
+
+ var payload = new Dictionary
+ {
+ ["name"] = go.name,
+ ["tag"] = go.tag,
+ ["layer"] = go.layer,
+ ["scene"] = go.scene.name,
+ ["is_prefab"] = PrefabUtility.IsPartOfAnyPrefab(go)
+ };
+
+ RecordEvent(EventTypes.GameObjectCreated, globalId, payload);
+ }
+ }
+
+ // Check for destroyed GameObjects
+ var destroyedIds = GameObjectTrackingHelper.GetDestroyedInstanceIds();
+ foreach (int id in destroyedIds)
+ {
+ string globalId = $"Instance:{id}";
+
+ var payload = new Dictionary
+ {
+ ["instance_id"] = id,
+ ["destroyed"] = true
+ };
+
+ RecordEvent(EventTypes.GameObjectDestroyed, globalId, payload);
+ }
+ }
+
+ #endregion
+
+ #region Selection Events
+
+ ///
+ /// Handles Selection changes (P2.3: Selection Tracking).
+ /// Records what the user is currently focusing on for AI context awareness.
+ ///
+ private static void OnSelectionChanged()
+ {
+ if (Selection.activeObject == null)
+ return;
+
+ string globalId = GlobalIdHelper.ToGlobalIdString(Selection.activeObject);
+
+ var payload = new Dictionary
+ {
+ ["name"] = Selection.activeObject.name,
+ ["type"] = Selection.activeObject.GetType().Name,
+ ["instance_id"] = Selection.activeObject.GetInstanceID()
+ };
+
+ // Add path for GameObject/Component selections
+ if (Selection.activeObject is GameObject go)
+ {
+ payload["path"] = GetGameObjectPath(go);
+ }
+ else if (Selection.activeObject is Component comp)
+ {
+ payload["path"] = GetGameObjectPath(comp.gameObject);
+ payload["component_type"] = comp.GetType().Name;
+ }
+
+ RecordEvent(EventTypes.SelectionChanged, globalId, payload);
+ }
+
+ #endregion
+
+ #region Hierarchy Events
+
+ ///
+ /// Handles hierarchy changes with debouncing.
+ ///
+ private static void OnHierarchyChanged()
+ {
+ var now = DateTime.Now;
+ lock (_lock)
+ {
+ // Debounce: ignore changes within 200ms of the last one
+ if ((now - _lastHierarchyChange).TotalMilliseconds < 200)
+ {
+ return;
+ }
+ _lastHierarchyChange = now;
+ }
+
+ RecordEvent(EventTypes.HierarchyChanged, "Scene", new Dictionary());
+ }
+
+ #endregion
+
+ #region Play Mode Events
+
+ ///
+ /// Handles play mode state changes.
+ ///
+ private static void OnPlayModeStateChanged(PlayModeStateChange state)
+ {
+ var payload = new Dictionary
+ {
+ ["state"] = state.ToString()
+ };
+
+ RecordEvent(EventTypes.PlayModeChanged, "Editor", payload);
+ }
+
+ #endregion
+
+ #region Scene Events
+
+ ///
+ /// Called when a scene is about to be saved.
+ ///
+ private static void OnSceneSaving(Scene scene, string path)
+ {
+ string targetId = string.IsNullOrEmpty(path) ? scene.name : $"Asset:{path}";
+
+ var payload = new Dictionary
+ {
+ ["scene_name"] = scene.name,
+ ["path"] = path,
+ ["root_count"] = scene.rootCount
+ };
+
+ RecordEvent(EventTypes.SceneSaving, targetId, payload);
+ }
+
+ ///
+ /// Called after a scene has been saved.
+ ///
+ private static void OnSceneSaved(Scene scene)
+ {
+ string path = scene.path;
+ string targetId = string.IsNullOrEmpty(path) ? scene.name : $"Asset:{path}";
+
+ var payload = new Dictionary
+ {
+ ["scene_name"] = scene.name,
+ ["path"] = path,
+ ["root_count"] = scene.rootCount
+ };
+
+ RecordEvent(EventTypes.SceneSaved, targetId, payload);
+ }
+
+ ///
+ /// Called when a scene is opened.
+ ///
+ private static void OnSceneOpened(Scene scene, OpenSceneMode mode)
+ {
+ string path = scene.path;
+ string targetId = string.IsNullOrEmpty(path) ? scene.name : $"Asset:{path}";
+
+ var payload = new Dictionary
+ {
+ ["scene_name"] = scene.name,
+ ["path"] = path,
+ ["mode"] = mode.ToString(),
+ ["root_count"] = scene.rootCount
+ };
+
+ RecordEvent(EventTypes.SceneOpened, targetId, payload);
+ }
+
+ ///
+ /// Called when a new scene is created.
+ ///
+ private static void OnNewSceneCreated(Scene scene, NewSceneSetup setup, NewSceneMode mode)
+ {
+ string targetId = $"Scene:{scene.name}";
+
+ var payload = new Dictionary
+ {
+ ["scene_name"] = scene.name,
+ ["setup"] = setup.ToString(),
+ ["mode"] = mode.ToString()
+ };
+
+ RecordEvent(EventTypes.NewSceneCreated, targetId, payload);
+ }
+
+ ///
+ /// Called when a scene is loaded (after load operation completes).
+ /// Resets GameObject tracking to clear stale data from previous scenes.
+ /// This fixes the issue where GameObjectCreated events were missed after scene switches.
+ ///
+ private static void OnSceneLoaded(Scene scene, LoadSceneMode mode)
+ {
+ // Reset tracking to clear stale data from previous scenes
+ GameObjectTrackingHelper.Reset();
+ GameObjectTrackingHelper.InitializeTracking();
+ }
+
+ ///
+ /// Called when a scene is unloaded.
+ /// Resets GameObject tracking before scene unloads.
+ ///
+ private static void OnSceneUnloaded(Scene scene)
+ {
+ // Reset tracking before clearing the scene
+ GameObjectTrackingHelper.Reset();
+ }
+
+ #endregion
+
+ #region Script Compilation Events
+
+ ///
+ /// Track script compilation state changes.
+ /// Unity doesn't provide direct events, so we monitor EditorApplication.isCompiling.
+ ///
+ private static void TrackScriptCompilation()
+ {
+ bool isNowCompiling = EditorApplication.isCompiling;
+
+ if (isNowCompiling && !_isCompiling)
+ {
+ // Compilation just started
+ _compileStartTime = DateTime.UtcNow;
+ _isCompiling = true;
+ }
+ else if (!isNowCompiling && _isCompiling)
+ {
+ // Compilation just finished
+ _isCompiling = false;
+
+ var duration = DateTime.UtcNow - _compileStartTime;
+ int scriptCount = CountScripts();
+
+ // Check if compilation was successful
+ int errorCount = GetCompilationErrorCount();
+
+ var payload = new Dictionary
+ {
+ ["script_count"] = scriptCount,
+ ["duration_ms"] = (long)duration.TotalMilliseconds
+ };
+
+ if (errorCount > 0)
+ {
+ payload["error_count"] = errorCount;
+ RecordEvent(EventTypes.ScriptCompilationFailed, "Editor", payload);
+ }
+ else
+ {
+ RecordEvent(EventTypes.ScriptCompiled, "Editor", payload);
+ }
+ }
+ }
+
+ ///
+ /// Count the number of script files in the project.
+ ///
+ private static int CountScripts()
+ {
+ try
+ {
+ return AssetDatabase.FindAssets("t:Script").Length;
+ }
+ catch
+ {
+ return 0;
+ }
+ }
+
+ ///
+ /// Get the current compilation error count.
+ ///
+ private static int GetCompilationErrorCount()
+ {
+ try
+ {
+ // Try to get error count from console
+ var assembly = typeof(UnityEditor.EditorUtility).Assembly;
+ var type = assembly.GetType("UnityEditor.Scripting.ScriptCompilationErrorCount");
+ if (type != null)
+ {
+ var property = type.GetProperty("errorCount", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public);
+ if (property != null)
+ {
+ var value = property.GetValue(null);
+ if (value is int count)
+ return count;
+ }
+ }
+
+ return 0;
+ }
+ catch
+ {
+ return 0;
+ }
+ }
+
+ #endregion
+
+ #region Build Events
+
+ ///
+ /// Custom build handler for tracking build events.
+ ///
+ private static void BuildPlayerHandler(BuildPlayerOptions options)
+ {
+ _buildStartTime = DateTime.UtcNow;
+ _currentBuildPlatform = BuildTargetUtility.GetBuildTargetName(options.target);
+
+ // Record build started
+ var startPayload = new Dictionary
+ {
+ ["platform"] = _currentBuildPlatform,
+ ["location"] = options.locationPathName,
+ ["scene_count"] = options.scenes.Length
+ };
+ RecordEvent(EventTypes.BuildStarted, "Build", startPayload);
+
+ // Execute the build
+ var result = BuildPipeline.BuildPlayer(options);
+
+ // Record build result
+ var duration = DateTime.UtcNow - _buildStartTime;
+
+ if (result.summary.result == UnityEditor.Build.Reporting.BuildResult.Succeeded)
+ {
+ var successPayload = new Dictionary
+ {
+ ["platform"] = _currentBuildPlatform,
+ ["location"] = options.locationPathName,
+ ["duration_ms"] = (long)duration.TotalMilliseconds,
+ ["size_bytes"] = result.summary.totalSize,
+ ["size_mb"] = result.summary.totalSize / (1024.0 * 1024.0)
+ };
+ RecordEvent(EventTypes.BuildCompleted, "Build", successPayload);
+ }
+ else
+ {
+ var failPayload = new Dictionary
+ {
+ ["platform"] = _currentBuildPlatform,
+ ["location"] = options.locationPathName,
+ ["duration_ms"] = (long)duration.TotalMilliseconds,
+ ["error"] = result.summary.ToString()
+ };
+ RecordEvent(EventTypes.BuildFailed, "Build", failPayload);
+ }
+
+ _currentBuildPlatform = null;
+ }
+
+ #endregion
+
+ #region Helpers
+
+ ///
+ /// Gets the full Hierarchy path for a GameObject.
+ /// Example: "Level1/Player/Arm/Hand"
+ ///
+ private static string GetGameObjectPath(GameObject obj)
+ {
+ if (obj == null)
+ return "Unknown";
+
+ var path = obj.name;
+ var parent = obj.transform.parent;
+
+ while (parent != null)
+ {
+ path = $"{parent.name}/{path}";
+ parent = parent.parent;
+ }
+
+ return path;
+ }
+
+ ///
+ /// Records an event to the EventStore with proper context injection.
+ ///
+ private static void RecordEvent(string type, string targetId, Dictionary payload)
+ {
+ try
+ {
+ // Inject VCS context into all recorded events
+ var vcsContext = VCS.VcsContextProvider.GetCurrentContext();
+ payload["vcs_context"] = vcsContext.ToDictionary();
+
+ // Inject Undo Group ID for undo_to_sequence functionality (P2.4)
+ int currentUndoGroup = Undo.GetCurrentGroup();
+ payload["undo_group"] = currentUndoGroup;
+
+ var evt = new EditorEvent(
+ sequence: 0, // Will be assigned by EventStore.Record
+ timestampUnixMs: DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ type: type,
+ targetId: targetId,
+ payload: payload
+ );
+
+ // Apply sampling middleware to protect from event floods.
+ // If sampling filters this event, do not record it here.
+ if (SamplingMiddleware.ShouldRecord(evt))
+ {
+ Core.EventStore.Record(evt);
+ }
+ }
+ catch (Exception ex)
+ {
+ McpLog.Warn($"[UnityEventHooks] Failed to record event: {ex.Message}");
+ }
+ }
+ }
+
+ #endregion
+}
diff --git a/MCPForUnity/Editor/ActionTrace/Context/ContextMapping.cs b/MCPForUnity/Editor/ActionTrace/Context/ContextMapping.cs
new file mode 100644
index 000000000..f79698943
--- /dev/null
+++ b/MCPForUnity/Editor/ActionTrace/Context/ContextMapping.cs
@@ -0,0 +1,60 @@
+using System;
+
+namespace MCPForUnity.Editor.ActionTrace.Context
+{
+ ///
+ /// Side-Table mapping between events and contexts.
+ /// This keeps the "bedrock" event layer pure while allowing context association.
+ /// Events remain immutable - context is stored separately.
+ ///
+ /// Design principle:
+ /// - EditorEvent = immutable facts (what happened)
+ /// - ContextMapping = mutable metadata (who did it, why)
+ ///
+ public sealed class ContextMapping : IEquatable
+ {
+ ///
+ /// The sequence number of the associated EditorEvent.
+ ///
+ public long EventSequence { get; }
+
+ ///
+ /// The unique identifier of the OperationContext.
+ ///
+ public Guid ContextId { get; }
+
+ public ContextMapping(long eventSequence, Guid contextId)
+ {
+ EventSequence = eventSequence;
+ ContextId = contextId;
+ }
+
+ public bool Equals(ContextMapping other)
+ {
+ if (ReferenceEquals(null, other)) return false;
+ if (ReferenceEquals(this, other)) return true;
+ return EventSequence == other.EventSequence
+ && ContextId.Equals(other.ContextId);
+ }
+
+ public override bool Equals(object obj)
+ {
+ return Equals(obj as ContextMapping);
+ }
+
+ public override int GetHashCode()
+ {
+ return HashCode.Combine(EventSequence, ContextId);
+ }
+
+ public static bool operator ==(ContextMapping left, ContextMapping right)
+ {
+ return Equals(left, right);
+ }
+
+ public static bool operator !=(ContextMapping left, ContextMapping right)
+ {
+ return !Equals(left, right);
+ }
+ }
+}
diff --git a/MCPForUnity/Editor/ActionTrace/Context/ContextStack.cs b/MCPForUnity/Editor/ActionTrace/Context/ContextStack.cs
new file mode 100644
index 000000000..7beabb204
--- /dev/null
+++ b/MCPForUnity/Editor/ActionTrace/Context/ContextStack.cs
@@ -0,0 +1,272 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using System.Threading;
+using MCPForUnity.Editor.Helpers;
+
+namespace MCPForUnity.Editor.ActionTrace.Context
+{
+ ///
+ /// Thread-local operation context stack for tracking operation source.
+ /// This is a "light marker" system - it doesn't control flow,
+ /// it only annotates operations with their source context.
+ ///
+ /// Design principle:
+ /// - Stack is lightweight (just references)
+ /// - No blocking operations
+ /// - Fast push/pop for using() pattern
+ /// - Thread-safe via ThreadStatic (each thread has its own stack)
+ ///
+ /// Threading model:
+ /// - Each thread maintains its own isolated context stack
+ /// - Unity Editor callbacks (delayCall, AssetPostprocessor) may run on different threads
+ /// - Context does not leak across thread boundaries
+ /// - Debug mode logs thread ID for diagnostics
+ ///
+ /// TODO-A Better clear strategy
+ public static class ContextStack
+ {
+ [ThreadStatic]
+ private static Stack _stack;
+
+ [ThreadStatic]
+ private static int _threadId; // For debug diagnostics
+
+ ///
+ /// Get the current operation context (if any).
+ /// Returns null if no context is active.
+ ///
+ public static OperationContext Current
+ {
+ get
+ {
+ var stack = GetStack();
+ return stack.Count > 0 ? stack.Peek() : null;
+ }
+ }
+
+ ///
+ /// Get the depth of the context stack.
+ ///
+ public static int Depth
+ {
+ get
+ {
+ return GetStack().Count;
+ }
+ }
+
+ ///
+ /// Get the thread-local stack, initializing if necessary.
+ ///
+ private static Stack GetStack()
+ {
+ if (_stack == null)
+ {
+ _stack = new Stack();
+ _threadId = Thread.CurrentThread.ManagedThreadId;
+
+#if DEBUG
+ McpLog.Info(
+ $"[ContextStack] Initialized new stack for thread {_threadId}");
+#endif
+ }
+ return _stack;
+ }
+
+ ///
+ /// Push a context onto the stack.
+ /// Returns a disposable that will pop the context when disposed.
+ ///
+ public static IDisposable Push(OperationContext context)
+ {
+ if (context == null)
+ throw new ArgumentNullException(nameof(context));
+
+ var stack = GetStack();
+ stack.Push(context);
+
+#if DEBUG
+ McpLog.Info(
+ $"[ContextStack] Push context {context.ContextId} on thread {_threadId}, depth: {stack.Count}");
+#endif
+
+ return new ContextDisposable(context);
+ }
+
+ ///
+ /// Pop the top context from the stack.
+ /// Validates that the popped context matches the expected one.
+ ///
+ public static bool Pop(OperationContext expectedContext)
+ {
+ var stack = GetStack();
+ if (stack.Count == 0)
+ {
+#if DEBUG
+ McpLog.Warn(
+ $"[ContextStack] Pop on empty stack (thread {_threadId}, expected {expectedContext?.ContextId})");
+#endif
+ return false;
+ }
+
+ var top = stack.Peek();
+ if (top.Equals(expectedContext))
+ {
+ stack.Pop();
+
+#if DEBUG
+ McpLog.Info(
+ $"[ContextStack] Pop context {expectedContext.ContextId} on thread {_threadId}, remaining depth: {stack.Count}");
+#endif
+
+ return true;
+ }
+
+ // Stack mismatch - this indicates a programming error
+ // Improvement: Only remove mismatched context, preserve valid ones
+ var currentThreadId = Thread.CurrentThread.ManagedThreadId;
+ var stackSnapshot = string.Join(", ", stack.Select(c => SafeGetShortId(c.ContextId)));
+
+ // Try to find and remove the mismatched context
+ var tempStack = new Stack();
+ bool found = false;
+
+ while (stack.Count > 0)
+ {
+ var item = stack.Pop();
+ if (item.Equals(expectedContext))
+ {
+ found = true;
+ break;
+ }
+ tempStack.Push(item);
+ }
+
+ // Restore valid contexts
+ while (tempStack.Count > 0)
+ {
+ stack.Push(tempStack.Pop());
+ }
+
+ if (!found)
+ {
+ McpLog.Warn(
+ $"[ContextStack] Expected context {expectedContext.ContextId} not found on thread {currentThreadId}\n" +
+ $" Stack snapshot: [{stackSnapshot}]\n" +
+ $" No changes made to stack.");
+ }
+
+ return found;
+ }
+
+ ///
+ /// Mark the current operation as an AI operation.
+ /// Returns a disposable for automatic cleanup.
+ ///
+ /// Usage:
+ /// using (ContextStack.MarkAsAiOperation("claude-opus"))
+ /// {
+ /// // All events recorded here are tagged as AI
+ /// }
+ ///
+ public static IDisposable MarkAsAiOperation(string agentId, string sessionId = null)
+ {
+ var context = OperationContextFactory.CreateAiContext(agentId, sessionId);
+ return Push(context);
+ }
+
+ ///
+ /// Mark the current operation as a human operation.
+ /// Returns a disposable for automatic cleanup.
+ ///
+ public static IDisposable MarkAsHumanOperation(string sessionId = null)
+ {
+ var context = OperationContextFactory.CreateHumanContext(sessionId);
+ return Push(context);
+ }
+
+ ///
+ /// Mark the current operation as a system operation.
+ /// Returns a disposable for automatic cleanup.
+ ///
+ public static IDisposable MarkAsSystemOperation(string sessionId = null)
+ {
+ var context = OperationContextFactory.CreateSystemContext(sessionId);
+ return Push(context);
+ }
+
+ ///
+ /// Check if the current context is from an AI source.
+ ///
+ public static bool IsAiOperation
+ {
+ get
+ {
+ var current = Current;
+ return current != null && current.Source == OperationSource.AI;
+ }
+ }
+
+ ///
+ /// Get the current agent ID (if AI operation).
+ ///
+ public static string CurrentAgentId
+ {
+ get
+ {
+ var current = Current;
+ return current?.Source == OperationSource.AI ? current.AgentId : null;
+ }
+ }
+
+ ///
+ /// Clear the entire stack (for error recovery).
+ /// Thread-safe: only clears the current thread's stack.
+ ///
+ public static void Clear()
+ {
+ var stack = GetStack();
+ stack.Clear();
+
+#if DEBUG
+ McpLog.Info(
+ $"[ContextStack] Cleared stack on thread {Thread.CurrentThread.ManagedThreadId}");
+#endif
+ }
+
+ ///
+ /// Safely extracts a short ID from a Guid, preventing null/empty exceptions.
+ ///
+ private static string SafeGetShortId(Guid guid)
+ {
+ var str = guid.ToString();
+ return str.Length >= 8 ? str.Substring(0, 8) : str;
+ }
+
+ ///
+ /// Disposable that pops the context when disposed.
+ /// Validates the context matches to prevent stack corruption.
+ ///
+ private sealed class ContextDisposable : IDisposable
+ {
+ private readonly OperationContext _context;
+ private bool _disposed;
+
+ public ContextDisposable(OperationContext context)
+ {
+ _context = context ?? throw new ArgumentNullException(nameof(context));
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+
+ Pop(_context);
+ _disposed = true;
+ }
+ }
+ }
+}
diff --git a/MCPForUnity/Editor/ActionTrace/Context/ContextTimeline.cs b/MCPForUnity/Editor/ActionTrace/Context/ContextTimeline.cs
new file mode 100644
index 000000000..349c1bedf
--- /dev/null
+++ b/MCPForUnity/Editor/ActionTrace/Context/ContextTimeline.cs
@@ -0,0 +1,77 @@
+using UnityEditor;
+using MCPForUnity.Editor.ActionTrace.Core;
+using System;
+using MCPForUnity.Editor.Helpers;
+
+namespace MCPForUnity.Editor.ActionTrace.Context
+{
+ ///
+ /// Automatically associates events with the current context.
+ /// Subscribes to EventStore.EventRecorded and creates mappings.
+ ///
+ /// This is the "glue" that connects the event layer to the context layer.
+ /// Events are immutable - context is attached via side-table mapping.
+ ///
+ /// Threading safety:
+ /// - EventStore.EventRecorded is raised via delayCall (next editor update)
+ /// - This callback runs on main thread, safe to call AddContextMapping
+ /// - AddContextMapping is thread-safe (uses _queryLock internally)
+ ///
+ [InitializeOnLoad]
+ public static class ContextTrace
+ {
+ static ContextTrace()
+ {
+ // Subscribe to event recording
+ // EventStore already uses delayCall, so this won't cause re-entrancy
+ Core.EventStore.EventRecorded += OnEventRecorded;
+ }
+
+ ///
+ /// Called when an event is recorded.
+ /// Associates the event with the current context (if any).
+ ///
+ private static void OnEventRecorded(EditorEvent @event)
+ {
+ try
+ {
+ var currentContext = ContextStack.Current;
+ if (currentContext != null)
+ {
+ // Create the mapping
+ var mapping = new ContextMapping(
+ eventSequence: @event.Sequence,
+ contextId: currentContext.ContextId
+ );
+
+ // Store in EventStore's side-table
+ Core.EventStore.AddContextMapping(mapping);
+ }
+ }
+ catch (System.Exception ex)
+ {
+ McpLog.Warn(
+ $"[ContextTrace] Failed to create context mapping: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Manually associate an event with a context.
+ /// Use this for batch operations or deferred association.
+ ///
+ public static void Associate(long eventSequence, Guid contextId)
+ {
+ var mapping = new ContextMapping(eventSequence, contextId);
+ Core.EventStore.AddContextMapping(mapping);
+ }
+
+ ///
+ /// Remove all mappings for a specific context.
+ /// Useful for cleanup after a batch operation.
+ ///
+ public static void DisassociateContext(Guid contextId)
+ {
+ Core.EventStore.RemoveContextMappings(contextId);
+ }
+ }
+}
diff --git a/MCPForUnity/Editor/ActionTrace/Context/OperationContext.cs b/MCPForUnity/Editor/ActionTrace/Context/OperationContext.cs
new file mode 100644
index 000000000..50be3ab14
--- /dev/null
+++ b/MCPForUnity/Editor/ActionTrace/Context/OperationContext.cs
@@ -0,0 +1,142 @@
+using System;
+
+namespace MCPForUnity.Editor.ActionTrace.Context
+{
+ ///
+ /// Operation source type.
+ /// Human: Manual editor operation
+ /// AI: AI-assisted operation (Claude, Cursor, etc.)
+ /// System: Automated system operation
+ ///
+ public enum OperationSource
+ {
+ Human,
+ AI,
+ System
+ }
+
+ ///
+ /// Immutable context metadata for an operation.
+ /// This is a "light marker" - minimal data that doesn't interfere with event storage.
+ /// Associated with events via Side-Table (ContextMapping), not embedded in EditorEvent.
+ ///
+ public sealed class OperationContext : IEquatable
+ {
+ ///
+ /// Unique identifier for this context instance.
+ ///
+ public Guid ContextId { get; }
+
+ ///
+ /// Source of the operation (Human, AI, or System).
+ ///
+ public OperationSource Source { get; }
+
+ ///
+ /// Agent identifier (e.g., "claude-opus", "cursor", "vscode-copilot").
+ /// Null for Human/System operations.
+ ///
+ public string AgentId { get; }
+
+ ///
+ /// Operation start time in UTC milliseconds since Unix epoch.
+ ///
+ public long StartTimeUnixMs { get; }
+
+ ///
+ /// Optional user/session identifier for correlation.
+ ///
+ public string SessionId { get; }
+
+ public OperationContext(
+ Guid contextId,
+ OperationSource source,
+ string agentId = null,
+ long startTimeUnixMs = 0,
+ string sessionId = null)
+ {
+ ContextId = contextId;
+ Source = source;
+ AgentId = agentId;
+ StartTimeUnixMs = startTimeUnixMs > 0
+ ? startTimeUnixMs
+ : DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+ SessionId = sessionId;
+ }
+
+ public bool Equals(OperationContext other)
+ {
+ if (ReferenceEquals(null, other)) return false;
+ if (ReferenceEquals(this, other)) return true;
+ return ContextId.Equals(other.ContextId);
+ }
+
+ public override bool Equals(object obj)
+ {
+ return Equals(obj as OperationContext);
+ }
+
+ public override int GetHashCode()
+ {
+ return ContextId.GetHashCode();
+ }
+
+ public static bool operator ==(OperationContext left, OperationContext right)
+ {
+ return Equals(left, right);
+ }
+
+ public static bool operator !=(OperationContext left, OperationContext right)
+ {
+ return !Equals(left, right);
+ }
+ }
+
+ ///
+ /// Factory for creating common context types.
+ ///
+ public static class OperationContextFactory
+ {
+ ///
+ /// Create a context for an AI operation.
+ ///
+ public static OperationContext CreateAiContext(string agentId, string sessionId = null)
+ {
+ return new OperationContext(
+ Guid.NewGuid(),
+ OperationSource.AI,
+ agentId,
+ DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ sessionId
+ );
+ }
+
+ ///
+ /// Create a context for a human operation.
+ ///
+ public static OperationContext CreateHumanContext(string sessionId = null)
+ {
+ return new OperationContext(
+ Guid.NewGuid(),
+ OperationSource.Human,
+ null,
+ DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ sessionId
+ );
+ }
+
+ ///
+ /// Create a context for a system operation.
+ ///
+ public static OperationContext CreateSystemContext(string sessionId = null)
+ {
+ return new OperationContext(
+ Guid.NewGuid(),
+ OperationSource.System,
+ null,
+ DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ sessionId
+ );
+ }
+ }
+}
diff --git a/MCPForUnity/Editor/ActionTrace/Context/ToolCallScope.cs b/MCPForUnity/Editor/ActionTrace/Context/ToolCallScope.cs
new file mode 100644
index 000000000..3544ab8c5
--- /dev/null
+++ b/MCPForUnity/Editor/ActionTrace/Context/ToolCallScope.cs
@@ -0,0 +1,537 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Text;
+using System.Threading;
+using MCPForUnity.Editor.ActionTrace.Core;
+
+namespace MCPForUnity.Editor.ActionTrace.Context
+{
+ ///
+ /// Represents a single tool call invocation scope.
+ /// Tracks the lifetime, events, and metadata of a tool call.
+ ///
+ public sealed class ToolCallScope : IDisposable
+ {
+ private static readonly ThreadLocal> _scopeStack =
+ new(() => new Stack());
+
+ private readonly string _toolName;
+ private readonly string _toolId;
+ private readonly Dictionary _parameters;
+ private readonly List _capturedEvents;
+ private readonly long _startTimestampMs;
+ private readonly List _childScopes;
+ private readonly ToolCallScope _parentScope;
+
+ private long _endTimestampMs;
+ private bool _isCompleted;
+ private string _result;
+ private string _errorMessage;
+ private bool _isDisposed;
+
+ ///
+ /// Unique identifier for this tool call.
+ ///
+ public string CallId { get; }
+
+ ///
+ /// Name of the tool being called.
+ ///
+ public string ToolName => _toolName;
+
+ ///
+ /// Optional tool identifier (for distinguishing overloaded tools).
+ ///
+ public string ToolId => _toolId;
+
+ ///
+ /// Parameters passed to the tool.
+ ///
+ public IReadOnlyDictionary Parameters => _parameters;
+
+ ///
+ /// Events captured during this tool call.
+ ///
+ public IReadOnlyList CapturedEvents => _capturedEvents;
+
+ ///
+ /// Child tool calls made during this scope.
+ ///
+ public IReadOnlyList ChildScopes => _childScopes;
+
+ ///
+ /// Parent scope if this is a nested call.
+ ///
+ public ToolCallScope Parent => _parentScope;
+
+ ///
+ /// Duration of the tool call in milliseconds.
+ ///
+ public long DurationMs => _endTimestampMs > 0
+ ? _endTimestampMs - _startTimestampMs
+ : DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - _startTimestampMs;
+
+ ///
+ /// Whether the tool call completed successfully.
+ ///
+ public bool IsCompleted => _isCompleted;
+
+ ///
+ /// Result of the tool call (if successful).
+ ///
+ public string Result => _result;
+
+ ///
+ /// Error message (if the call failed).
+ ///
+ public string ErrorMessage => _errorMessage;
+
+ ///
+ /// Current active scope for this thread.
+ ///
+ public static ToolCallScope Current => _scopeStack.Value.Count > 0 ? _scopeStack.Value.Peek() : null;
+
+ ///
+ /// Create a new tool call scope.
+ ///
+ public ToolCallScope(string toolName, string toolId = null, Dictionary parameters = null)
+ {
+ _toolName = toolName ?? throw new ArgumentNullException(nameof(toolName));
+ _toolId = toolId ?? toolName;
+ _parameters = parameters ?? new Dictionary();
+ _capturedEvents = new List();
+ _childScopes = new List();
+ _startTimestampMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+ _parentScope = Current;
+
+ CallId = GenerateCallId();
+
+ // Push to stack
+ _scopeStack.Value.Push(this);
+
+ // Notify parent
+ _parentScope?._childScopes.Add(this);
+
+ // Record start event
+ RecordStartEvent();
+ }
+
+ ///
+ /// Complete the tool call with a result.
+ ///
+ public void Complete(string result = null)
+ {
+ if (_isCompleted) return;
+
+ _result = result;
+ _isCompleted = true;
+ _endTimestampMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+
+ RecordCompletionEvent();
+ }
+
+ ///
+ /// Complete the tool call with an error.
+ ///
+ public void Fail(string errorMessage)
+ {
+ if (_isCompleted) return;
+
+ _errorMessage = errorMessage;
+ _isCompleted = true;
+ _endTimestampMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+
+ RecordErrorEvent();
+ }
+
+ ///
+ /// Record an event that occurred during this tool call.
+ ///
+ public void RecordEvent(EditorEvent evt)
+ {
+ if (evt != null && !_isDisposed)
+ {
+ _capturedEvents.Add(evt);
+ }
+ }
+
+ ///
+ /// Get all events from this scope and all child scopes (flattened).
+ ///
+ public List GetAllEventsFlattened()
+ {
+ var allEvents = new List(_capturedEvents);
+
+ foreach (var child in _childScopes)
+ {
+ allEvents.AddRange(child.GetAllEventsFlattened());
+ }
+
+ return allEvents;
+ }
+
+ ///
+ /// Get a summary of this tool call.
+ ///
+ public string GetSummary()
+ {
+ var summary = new StringBuilder();
+
+ summary.Append(_toolName);
+
+ if (_parameters.Count > 0)
+ {
+ summary.Append("(");
+ int i = 0;
+ foreach (var kvp in _parameters)
+ {
+ if (i > 0) summary.Append(", ");
+ summary.Append(kvp.Key).Append("=").Append(FormatValue(kvp.Value));
+ i++;
+ if (i >= 3)
+ {
+ summary.Append("...");
+ break;
+ }
+ }
+ summary.Append(")");
+ }
+
+ summary.Append($" [{DurationMs}ms]");
+
+ if (_errorMessage != null)
+ {
+ summary.Append($" ERROR: {_errorMessage}");
+ }
+ else if (_isCompleted)
+ {
+ summary.Append(" ✓");
+ }
+
+ if (_capturedEvents.Count > 0)
+ {
+ summary.Append($" ({_capturedEvents.Count} events)");
+ }
+
+ if (_childScopes.Count > 0)
+ {
+ summary.Append($" +{_childScopes.Count} nested calls");
+ }
+
+ return summary.ToString();
+ }
+
+ ///
+ /// Get detailed information about this tool call.
+ ///
+ public string GetDetails()
+ {
+ var details = new StringBuilder();
+
+ details.AppendLine($"=== Tool Call: {_toolName} ===");
+ details.AppendLine($"Call ID: {CallId}");
+ details.AppendLine($"Duration: {DurationMs}ms");
+ details.AppendLine($"Status: {_errorMessage ?? (_isCompleted ? "Completed" : "Running")}");
+
+ if (_parameters.Count > 0)
+ {
+ details.AppendLine("Parameters:");
+ foreach (var kvp in _parameters)
+ {
+ details.AppendLine($" {kvp.Key}: {FormatValue(kvp.Value)}");
+ }
+ }
+
+ if (_capturedEvents.Count > 0)
+ {
+ details.AppendLine($"Captured Events ({_capturedEvents.Count}):");
+ foreach (var evt in _capturedEvents)
+ {
+ details.AppendLine($" - [{evt.Type}] {evt.GetSummary()}");
+ }
+ }
+
+ if (_childScopes.Count > 0)
+ {
+ details.AppendLine($"Nested Calls ({_childScopes.Count}):");
+ foreach (var child in _childScopes)
+ {
+ details.AppendLine($" - {child.GetSummary()}");
+ }
+ }
+
+ if (_result != null)
+ {
+ details.AppendLine($"Result: {_result}");
+ }
+
+ return details.ToString();
+ }
+
+ public void Dispose()
+ {
+ if (_isDisposed) return;
+
+ // Auto-complete if not explicitly completed
+ if (!_isCompleted)
+ {
+ Complete();
+ }
+
+ // Pop from stack
+ if (_scopeStack.Value.Count > 0 && _scopeStack.Value.Peek() == this)
+ {
+ _scopeStack.Value.Pop();
+ }
+
+ _isDisposed = true;
+ }
+
+ private string GenerateCallId()
+ {
+ // Compact ID: tool name + timestamp + random suffix
+ long timestamp = _startTimestampMs % 1000000; // Last 6 digits of timestamp
+ int random = UnityEngine.Random.Range(1000, 9999);
+ return $"{_toolId}_{timestamp}_{random}";
+ }
+
+ private void RecordStartEvent()
+ {
+ var payload = new Dictionary
+ {
+ { "tool_name", _toolName },
+ { "call_id", CallId },
+ { "parent_call_id", _parentScope?.CallId ?? "" },
+ { "parameter_count", _parameters.Count }
+ };
+
+ foreach (var kvp in _parameters)
+ {
+ // Add parameters (truncated if too long)
+ string valueStr = FormatValue(kvp.Value);
+ if (valueStr != null && valueStr.Length > 100)
+ {
+ valueStr = valueStr.Substring(0, 97) + "...";
+ }
+ payload[$"param_{kvp.Key}"] = valueStr;
+ }
+
+ // Emit through EventStore
+ var evt = new EditorEvent(
+ sequence: 0, // Will be assigned by EventStore
+ timestampUnixMs: _startTimestampMs,
+ type: "ToolCallStarted",
+ targetId: CallId,
+ payload: payload
+ );
+ EventStore.Record(evt);
+ }
+
+ private void RecordCompletionEvent()
+ {
+ var payload = new Dictionary
+ {
+ { "tool_name", _toolName },
+ { "call_id", CallId },
+ { "duration_ms", DurationMs },
+ { "events_captured", _capturedEvents.Count },
+ { "nested_calls", _childScopes.Count }
+ };
+
+ if (_result != null && _result.Length <= 200)
+ {
+ payload["result"] = _result;
+ }
+
+ var completedEvt = new EditorEvent(
+ sequence: 0,
+ timestampUnixMs: _endTimestampMs,
+ type: "ToolCallCompleted",
+ targetId: CallId,
+ payload: payload
+ );
+ EventStore.Record(completedEvt);
+ }
+
+ private void RecordErrorEvent()
+ {
+ var payload = new Dictionary
+ {
+ { "tool_name", _toolName },
+ { "call_id", CallId },
+ { "duration_ms", DurationMs },
+ { "error", _errorMessage ?? "Unknown error" },
+ { "events_captured", _capturedEvents.Count }
+ };
+
+ var errorEvt = new EditorEvent(
+ sequence: 0,
+ timestampUnixMs: _endTimestampMs,
+ type: "ToolCallFailed",
+ targetId: CallId,
+ payload: payload
+ );
+ EventStore.Record(errorEvt);
+ }
+
+ private static string FormatValue(object value)
+ {
+ if (value == null) return "null";
+ if (value is string str) return $"\"{str}\"";
+ if (value is bool b) return b.ToString().ToLower();
+ return value.ToString();
+ }
+
+ // ========== Static Helper Methods ==========
+
+ ///
+ /// Create a new scope with automatic disposal.
+ /// Usage: using (ToolCallScope.Begin("manage_gameobject", params)) { ... }
+ ///
+ public static ToolCallScope Begin(string toolName, string toolId = null, Dictionary parameters = null)
+ {
+ return new ToolCallScope(toolName, toolId, parameters);
+ }
+
+ ///
+ /// Get the current scope's call ID (returns empty if no active scope).
+ ///
+ public static string GetCurrentCallId()
+ {
+ return Current?.CallId ?? "";
+ }
+
+ ///
+ /// Record an event in the current scope (if any).
+ ///
+ public static void RecordEventInCurrentScope(EditorEvent evt)
+ {
+ Current?.RecordEvent(evt);
+ }
+
+ ///
+ /// Get all active scopes in the current thread's hierarchy.
+ ///
+ public static List GetActiveHierarchy()
+ {
+ var hierarchy = new List();
+ var stack = _scopeStack.Value;
+
+ foreach (var scope in stack)
+ {
+ hierarchy.Add(scope);
+ }
+
+ hierarchy.Reverse(); // Root first
+ return hierarchy;
+ }
+
+ ///
+ /// Get the root scope (outermost call) in the current hierarchy.
+ ///
+ public static ToolCallScope GetRootScope()
+ {
+ var stack = _scopeStack.Value;
+ if (stack.Count == 0) return null;
+
+ // The bottom of the stack is the root
+ return stack.ToArray()[^1];
+ }
+ }
+
+ ///
+ /// Helper methods for common tool call instrumentation patterns.
+ ///
+ public static class ToolCall
+ {
+ ///
+ /// Execute a function within a tool call scope, automatically recording duration and result.
+ ///
+ public static T Execute(string toolName, Func func, string toolId = null, Dictionary parameters = null)
+ {
+ using var scope = new ToolCallScope(toolName, toolId, parameters);
+
+ try
+ {
+ T result = func();
+ scope.Complete(result?.ToString() ?? "");
+ return result;
+ }
+ catch (Exception ex)
+ {
+ scope.Fail(ex.Message);
+ throw;
+ }
+ }
+
+ ///
+ /// Execute an async function within a tool call scope.
+ ///
+ public static System.Threading.Tasks.Task ExecuteAsync(
+ string toolName,
+ Func> func,
+ string toolId = null,
+ Dictionary parameters = null)
+ {
+ var scope = new ToolCallScope(toolName, toolId, parameters);
+
+ var task = func();
+
+ return task.ContinueWith(t =>
+ {
+ if (t.IsFaulted)
+ {
+ scope.Fail(t.Exception?.Message ?? "Async faulted");
+ throw t.Exception ?? new Exception("Async task faulted");
+ }
+ else
+ {
+ scope.Complete(t.Result?.ToString() ?? "");
+ return t.Result;
+ }
+ }, System.Threading.Tasks.TaskScheduler.Default);
+ }
+
+ ///
+ /// Execute an action within a tool call scope.
+ ///
+ public static void Execute(string toolName, Action action, string toolId = null, Dictionary parameters = null)
+ {
+ using var scope = new ToolCallScope(toolName, toolId, parameters);
+
+ try
+ {
+ action();
+ scope.Complete();
+ }
+ catch (Exception ex)
+ {
+ scope.Fail(ex.Message);
+ throw;
+ }
+ }
+
+ ///
+ /// Measure execution time of a function without creating a scope.
+ ///
+ public static (T result, long ms) Measure(Func func)
+ {
+ var sw = Stopwatch.StartNew();
+ T result = func();
+ sw.Stop();
+ return (result, sw.ElapsedMilliseconds);
+ }
+
+ ///
+ /// Measure execution time of an action without creating a scope.
+ ///
+ public static long Measure(Action action)
+ {
+ var sw = Stopwatch.StartNew();
+ action();
+ sw.Stop();
+ return sw.ElapsedMilliseconds;
+ }
+ }
+}
diff --git a/MCPForUnity/Editor/ActionTrace/Core/ActionTraceSettings.Editor.cs b/MCPForUnity/Editor/ActionTrace/Core/ActionTraceSettings.Editor.cs
new file mode 100644
index 000000000..d39b02f1f
--- /dev/null
+++ b/MCPForUnity/Editor/ActionTrace/Core/ActionTraceSettings.Editor.cs
@@ -0,0 +1,179 @@
+using System;
+using UnityEngine;
+using UnityEditor;
+
+namespace MCPForUnity.Editor.ActionTrace.Core
+{
+ ///
+ /// Custom editor for ActionTraceSettings with layered UI.
+ ///
+ [CustomEditor(typeof(ActionTraceSettings))]
+ public sealed class ActionTraceSettingsEditor : UnityEditor.Editor
+ {
+ private Vector2 _scrollPos;
+ private bool _showFiltering = true;
+ private bool _showMerging = true;
+ private bool _showStorage = true;
+ private bool _showSampling = true;
+
+ public override void OnInspectorGUI()
+ {
+ var settings = (ActionTraceSettings)target;
+
+ serializedObject.Update();
+
+ EditorGUILayout.Space(6);
+ using (new GUILayout.HorizontalScope())
+ {
+ GUILayout.Label("ActionTrace Settings", EditorStyles.boldLabel);
+ GUILayout.FlexibleSpace();
+ if (GUILayout.Button("Validate", GUILayout.Width(80)))
+ {
+ ValidateAndShowIssues(settings);
+ }
+ }
+
+ EditorGUILayout.HelpBox(
+ "Layered settings configuration. Changes affect event capture behavior, not just UI display.",
+ MessageType.Info);
+ EditorGUILayout.Space(4);
+
+ _scrollPos = EditorGUILayout.BeginScrollView(_scrollPos);
+
+ // Presets Section
+ DrawPresetsSection(settings);
+ EditorGUILayout.Space(8);
+
+ // Layered Settings Sections
+ DrawFilteringSection(settings);
+ DrawMergingSection(settings);
+ DrawStorageSection(settings);
+ DrawSamplingSection(settings);
+
+ EditorGUILayout.EndScrollView();
+
+ // Apply changes only if properties were actually modified
+ if (serializedObject.ApplyModifiedProperties())
+ {
+ settings.MarkDirty();
+ settings.Save();
+ }
+ }
+
+ private void DrawPresetsSection(ActionTraceSettings settings)
+ {
+ EditorGUILayout.LabelField("Preset Configuration", EditorStyles.boldLabel);
+ EditorGUILayout.HelpBox(
+ $"Current: {settings.CurrentPresetName} | Est. Memory: {settings.GetEstimatedMemoryUsageString()}",
+ MessageType.None);
+
+ using (new GUILayout.HorizontalScope())
+ {
+ foreach (var preset in ActionTracePreset.AllPresets)
+ {
+ if (GUILayout.Button(preset.Name))
+ {
+ settings.ApplyPreset(preset);
+ GUI.changed = true;
+ }
+ }
+ }
+
+ // Show description of current preset
+ var currentPreset = ActionTracePreset.AllPresets.Find(p => p.Name == settings.CurrentPresetName);
+ if (currentPreset != null)
+ {
+ EditorGUILayout.HelpBox(currentPreset.Description, MessageType.None);
+ }
+ }
+
+ private void DrawFilteringSection(ActionTraceSettings settings)
+ {
+ _showFiltering = EditorGUILayout.Foldout(_showFiltering, "Event Filtering", true, EditorStyles.boldLabel);
+ if (!_showFiltering) return;
+
+ using (new EditorGUI.IndentLevelScope())
+ {
+ EditorGUILayout.PropertyField(serializedObject.FindProperty(nameof(ActionTraceSettings.Filtering)),
+ new GUIContent("Filter Settings", "Controls which events are recorded"));
+
+ EditorGUILayout.HelpBox(
+ "MinImportance: 0.0=all, 0.4=medium+, 0.7=high+\n" +
+ "Bypass: Skip filter, record all events",
+ MessageType.None);
+ }
+ EditorGUILayout.Space(4);
+ }
+
+ private void DrawMergingSection(ActionTraceSettings settings)
+ {
+ _showMerging = EditorGUILayout.Foldout(_showMerging, "Event Merging", true, EditorStyles.boldLabel);
+ if (!_showMerging) return;
+
+ using (new EditorGUI.IndentLevelScope())
+ {
+ EditorGUILayout.PropertyField(serializedObject.FindProperty(nameof(ActionTraceSettings.Merging)),
+ new GUIContent("Merge Settings", "Controls high-frequency event merging and transaction aggregation"));
+
+ EditorGUILayout.HelpBox(
+ "MergeWindow: Similar events within window are merged\n" +
+ "TransactionWindow: Events within window grouped into same transaction",
+ MessageType.None);
+ }
+ EditorGUILayout.Space(4);
+ }
+
+ private void DrawStorageSection(ActionTraceSettings settings)
+ {
+ _showStorage = EditorGUILayout.Foldout(_showStorage, "Storage Management", true, EditorStyles.boldLabel);
+ if (!_showStorage) return;
+
+ using (new EditorGUI.IndentLevelScope())
+ {
+ EditorGUILayout.PropertyField(serializedObject.FindProperty(nameof(ActionTraceSettings.Storage)),
+ new GUIContent("Storage Settings", "Controls event storage and memory management"));
+
+ EditorGUILayout.HelpBox(
+ $"MaxEvents: Soft limit (hard limit = MaxEvents × 2, range: 100-5000)\n" +
+ $"HotEventCount: Events kept in memory with full payload\n" +
+ $"ContextMappings: MaxEvents × 2 (e.g., 800→1600, 5000→10000)\n" +
+ $"Est. Memory: {settings.GetEstimatedMemoryUsageString()}",
+ MessageType.None);
+ }
+ EditorGUILayout.Space(4);
+ }
+
+ private void DrawSamplingSection(ActionTraceSettings settings)
+ {
+ _showSampling = EditorGUILayout.Foldout(_showSampling, "Sampling Configuration", true, EditorStyles.boldLabel);
+ if (!_showSampling) return;
+
+ using (new EditorGUI.IndentLevelScope())
+ {
+ EditorGUILayout.PropertyField(serializedObject.FindProperty(nameof(ActionTraceSettings.Sampling)),
+ new GUIContent("Sampling Settings", "Controls sampling behavior for high-frequency events"));
+
+ EditorGUILayout.HelpBox(
+ "Hierarchy: Hierarchy change event sampling interval\n" +
+ "Selection: Selection change event sampling interval\n" +
+ "Property: Property modification event sampling interval",
+ MessageType.None);
+ }
+ EditorGUILayout.Space(4);
+ }
+
+ private void ValidateAndShowIssues(ActionTraceSettings settings)
+ {
+ var issues = settings.Validate();
+ if (issues.Count == 0)
+ {
+ EditorUtility.DisplayDialog("Validation Passed", "All settings are valid!", "OK");
+ }
+ else
+ {
+ var message = string.Join("\n", issues);
+ EditorUtility.DisplayDialog("Validation Issues", $"Found the following issues:\n\n{message}", "OK");
+ }
+ }
+ }
+}
diff --git a/MCPForUnity/Editor/ActionTrace/Core/ActionTraceSettings.cs b/MCPForUnity/Editor/ActionTrace/Core/ActionTraceSettings.cs
new file mode 100644
index 000000000..e2907d762
--- /dev/null
+++ b/MCPForUnity/Editor/ActionTrace/Core/ActionTraceSettings.cs
@@ -0,0 +1,285 @@
+using System;
+using System.Collections.Generic;
+using UnityEngine;
+using UnityEditor;
+using MCPForUnity.Editor.Helpers;
+
+namespace MCPForUnity.Editor.ActionTrace.Core
+{
+ ///
+ /// Layered settings for event filtering.
+ /// Controls which events are recorded at the capture layer.
+ ///
+ [Serializable]
+ public sealed class FilteringSettings
+ {
+ [Range(0f, 1f)]
+ [Tooltip("Minimum importance threshold (0.0-1.0). Events below this value will not be recorded. 0.0=all, 0.4=medium+, 0.7=high+")]
+ public float MinImportanceForRecording = 0.4f;
+
+ [Tooltip("Bypass importance filter - Disabled for now to avoid excessive data volume")]
+ public bool BypassImportanceFilter = false;
+
+ [Tooltip("List of disabled event types. Empty means all enabled.")]
+ public string[] DisabledEventTypes = Array.Empty();
+
+ [Tooltip("(Future Feature,not used now) Enable emergency AI trigger. Critical events (score 10.0) will interrupt AI request attention.")]
+ public bool EnableEmergencyAITrigger = true;
+ }
+
+ ///
+ /// Layered settings for event merging and aggregation.
+ /// Controls how high-frequency events are combined.
+ ///
+ [Serializable]
+ public sealed class MergingSettings
+ {
+ [Tooltip("Enable event merging. High-frequency events will be merged within the time window.")]
+ public bool EnableEventMerging = true;
+
+ [Range(0, 5000)]
+ [Tooltip("Event merging time window (0-5000ms). High-frequency events within this window are merged.")]
+ public int MergeWindowMs = 100;
+
+ [Range(100, 10000)]
+ [Tooltip("Transaction aggregation time window (100-10000ms). Events within this window are grouped into the same logical transaction.")]
+ public int TransactionWindowMs = 2000;
+ }
+
+ ///
+ /// Layered settings for storage and memory management.
+ /// Controls event store size and dehydration behavior.
+ ///
+ [Serializable]
+ public sealed class StorageSettings
+ {
+ [Range(100, 5000)]
+ [Tooltip("Soft limit: target event count (100-5000). ContextMappings = MaxEvents × 2 (e.g., 1000→2000, 5000→10000).")]
+ public int MaxEvents = 800;
+
+ [Range(10, 1000)]
+ [Tooltip("Number of hot events (10-1000) to retain with full payload. Older events will be dehydrated (Payload=null).")]
+ public int HotEventCount = 150;
+
+ [Tooltip("Minimum number of events to keep when auto-cleaning.")]
+ public int MinKeepEvents = 100;
+
+ [Tooltip("Enable cross-domain reload persistence.")]
+ public bool EnablePersistence = true;
+
+ [Tooltip("Auto-save interval in seconds. 0 = disable auto-save.")]
+ public int AutoSaveIntervalSeconds = 30;
+ }
+
+ ///
+ /// Layered settings for sampling and throttling.
+ /// Controls how high-frequency events are sampled.
+ ///
+ [Serializable]
+ public sealed class SamplingSettings
+ {
+ [Tooltip("Enable global sampling. Events below threshold will be sampled.")]
+ public bool EnableSampling = true;
+
+ [Tooltip("Sampling importance threshold. Events below this value may be sampled.")]
+ public float SamplingImportanceThreshold = 0.3f;
+
+ [Tooltip("HierarchyChanged event sampling interval (milliseconds).")]
+ public int HierarchySamplingMs = 1000;
+
+ [Tooltip("SelectionChanged event sampling interval (milliseconds).")]
+ public int SelectionSamplingMs = 500;
+
+ [Tooltip("PropertyModified event sampling interval (milliseconds).")]
+ public int PropertySamplingMs = 200;
+ }
+
+ ///
+ /// Persistent settings for the ActionTrace system.
+ /// Organized into logical layers for better clarity and maintainability.
+ ///
+ [CreateAssetMenu(fileName = "ActionTraceSettings", menuName = "ActionTrace/Settings")]
+ public sealed class ActionTraceSettings : ScriptableObject
+ {
+ private const string SettingsPath = "Assets/ActionTraceSettings.asset";
+
+ private static ActionTraceSettings _instance;
+
+ // ========== Layered Settings ==========
+ // Headers are rendered by ActionTraceSettingsEditor, not here.
+
+ public FilteringSettings Filtering = new();
+ public MergingSettings Merging = new();
+ public StorageSettings Storage = new();
+ public SamplingSettings Sampling = new();
+
+ // ========== Runtime State ==========
+
+ [NonSerialized]
+ private string _currentPresetName = "Standard";
+
+ [NonSerialized]
+ private bool _isDirty;
+
+ // ========== Singleton Access ==========
+
+ ///
+ /// Gets or creates the singleton settings instance.
+ ///
+ public static ActionTraceSettings Instance
+ {
+ get
+ {
+ if (_instance == null)
+ {
+ _instance = LoadSettings();
+ if (_instance == null)
+ {
+ _instance = CreateSettings();
+ }
+ }
+ return _instance;
+ }
+ }
+
+ ///
+ /// Currently active preset name.
+ ///
+ public string CurrentPresetName => _currentPresetName;
+
+ ///
+ /// Whether settings have unsaved changes.
+ ///
+ public bool IsDirty => _isDirty;
+
+ // ========== Preset Management ==========
+
+ ///
+ /// Apply a preset configuration to this settings instance.
+ ///
+ public void ApplyPreset(ActionTracePreset preset)
+ {
+ if (preset == null) return;
+
+ Filtering.MinImportanceForRecording = preset.MinImportance;
+ Storage.MaxEvents = preset.MaxEvents;
+ Storage.HotEventCount = preset.HotEventCount;
+ Merging.EnableEventMerging = preset.EnableEventMerging;
+ Merging.MergeWindowMs = preset.MergeWindowMs;
+ Merging.TransactionWindowMs = preset.TransactionWindowMs;
+
+ _currentPresetName = preset.Name;
+ MarkDirty();
+ Save();
+
+ McpLog.Info($"[ActionTraceSettings] Applied preset: {preset.Name}");
+ }
+
+ ///
+ /// Get all available presets.
+ ///
+ public static List GetPresets() => ActionTracePreset.AllPresets;
+
+ ///
+ /// Find preset by name.
+ ///
+ public static ActionTracePreset FindPreset(string name)
+ {
+ return ActionTracePreset.AllPresets.Find(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
+ }
+
+ // ========== Persistence ==========
+
+ ///
+ /// Reloads settings from disk.
+ ///
+ public static void Reload()
+ {
+ _instance = LoadSettings();
+ }
+
+ private static ActionTraceSettings LoadSettings()
+ {
+ return AssetDatabase.LoadAssetAtPath(SettingsPath);
+ }
+
+ private static ActionTraceSettings CreateSettings()
+ {
+ var settings = CreateInstance();
+ // Apply Standard preset by default
+ settings.ApplyPreset(ActionTracePreset.Standard);
+ AssetDatabase.CreateAsset(settings, SettingsPath);
+ AssetDatabase.SaveAssets();
+ AssetDatabase.Refresh();
+ McpLog.Info($"[ActionTraceSettings] Created new settings at {SettingsPath}");
+ return settings;
+ }
+
+ ///
+ /// Saves the current settings to disk.
+ ///
+ public void Save()
+ {
+ EditorUtility.SetDirty(this);
+ AssetDatabase.SaveAssets();
+ _isDirty = false;
+ McpLog.Info("[ActionTraceSettings] Settings saved");
+ }
+
+ ///
+ /// Mark settings as dirty (unsaved changes).
+ ///
+ public void MarkDirty()
+ {
+ _isDirty = true;
+ }
+
+ ///
+ /// Shows the settings inspector window.
+ ///
+ public static void ShowSettingsWindow()
+ {
+ Selection.activeObject = Instance;
+ EditorApplication.ExecuteMenuItem("Window/General/Inspector");
+ }
+
+ ///
+ /// Validates settings and returns any issues.
+ ///
+ public List Validate()
+ {
+ var issues = new List();
+
+ // Note: MinImportanceForRecording, MergeWindowMs, TransactionWindowMs, HotEventCount
+ // are now constrained by Range attributes in Inspector.
+
+ // Dynamic validation: HotEventCount should not exceed MaxEvents (runtime check)
+ if (Storage.HotEventCount > Storage.MaxEvents)
+ issues.Add("HotEventCount should not exceed MaxEvents");
+
+ return issues;
+ }
+
+ ///
+ /// Get estimated memory usage in bytes.
+ ///
+ public long GetEstimatedMemoryUsage()
+ {
+ // Approximate: each event ~300 bytes when hydrated, ~100 bytes when dehydrated
+ int hotEvents = Storage.HotEventCount;
+ int coldEvents = Storage.MaxEvents - Storage.HotEventCount;
+ return (long)(hotEvents * 300 + coldEvents * 100);
+ }
+
+ ///
+ /// Get estimated memory usage as human-readable string.
+ ///
+ public string GetEstimatedMemoryUsageString()
+ {
+ long bytes = GetEstimatedMemoryUsage();
+ return bytes < 1024 ? $"{bytes} B"
+ : bytes < 1024 * 1024 ? $"{bytes / 1024} KB"
+ : $"{bytes / (1024 * 1024)} MB";
+ }
+ }
+}
diff --git a/MCPForUnity/Editor/ActionTrace/Core/EditorEvent.cs b/MCPForUnity/Editor/ActionTrace/Core/EditorEvent.cs
new file mode 100644
index 000000000..9670cdfc2
--- /dev/null
+++ b/MCPForUnity/Editor/ActionTrace/Core/EditorEvent.cs
@@ -0,0 +1,396 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Linq;
+using MCPForUnity.Editor.Helpers;
+using Newtonsoft.Json;
+
+namespace MCPForUnity.Editor.ActionTrace.Core
+{
+ ///
+ /// Immutable class representing a single editor event.
+ /// This is the "bedrock" layer - once written, never modified.
+ ///
+ /// Memory optimization (Pruning):
+ /// - Payload can be null for old events (automatically dehydrated by EventStore)
+ /// - PrecomputedSummary is always available, even when Payload is null
+ /// - This reduces memory from ~10KB to ~100 bytes per old event
+ ///
+ /// Payload serialization constraints:
+ /// - Only JSON-serializable types are allowed: string, number (int/long/float/double/decimal),
+ /// bool, null, array of these types, or Dictionary with these value types.
+ /// - Unsupported types (UnityEngine.Object, MonoBehaviour, etc.) are logged and skipped.
+ ///
+ public sealed class EditorEvent : IEquatable
+ {
+ // Limits to protect memory usage for payloads
+ private const int MaxStringLength = 512; // truncate long strings
+ private const int MaxCollectionItems = 64; // max items to keep in arrays/lists
+ private const int MaxSanitizeDepth = 4; // prevent deep recursion
+
+ ///
+ /// Monotonically increasing sequence number for ordering.
+ /// JSON property name: "sequence"
+ ///
+ [JsonProperty("sequence")]
+ public long Sequence { get; }
+
+ ///
+ /// UTC timestamp in milliseconds since Unix epoch.
+ /// JSON property name: "timestamp_unix_ms"
+ ///
+ [JsonProperty("timestamp_unix_ms")]
+ public long TimestampUnixMs { get; }
+
+ ///
+ /// Event type identifier (e.g., "GameObjectCreated", "ComponentAdded").
+ /// JSON property name: "type"
+ ///
+ [JsonProperty("type")]
+ public string Type { get; }
+
+ ///
+ /// Target identifier (instance ID, asset GUID, or file path).
+ /// JSON property name: "target_id"
+ ///
+ [JsonProperty("target_id")]
+ public string TargetId { get; }
+
+ ///
+ /// Event payload containing additional context data.
+ /// All values are guaranteed to be JSON-serializable.
+ ///
+ /// Can be null for old events (after dehydration).
+ /// Use PrecomputedSummary instead when Payload is null.
+ /// JSON property name: "payload"
+ ///
+ [JsonProperty("payload")]
+ public IReadOnlyDictionary Payload { get; }
+
+ ///
+ /// Precomputed summary for this event.
+ /// Always available, even when Payload has been dehydrated (null).
+ /// JSON property name: "precomputed_summary"
+ ///
+ [JsonProperty("precomputed_summary")]
+ public string PrecomputedSummary { get; private set; }
+
+ ///
+ /// Whether this event's payload has been dehydrated (trimmed to save memory).
+ /// JSON property name: "is_dehydrated"
+ ///
+ [JsonProperty("is_dehydrated")]
+ public bool IsDehydrated { get; private set; }
+
+ public EditorEvent(
+ long sequence,
+ long timestampUnixMs,
+ string type,
+ string targetId,
+ IReadOnlyDictionary payload)
+ {
+ Sequence = sequence;
+ TimestampUnixMs = timestampUnixMs;
+ Type = type ?? throw new ArgumentNullException(nameof(type));
+ TargetId = targetId ?? throw new ArgumentNullException(nameof(targetId));
+
+ // Validate and sanitize payload to ensure JSON-serializable types
+ if (payload == null)
+ {
+ Payload = null;
+ PrecomputedSummary = null;
+ IsDehydrated = false;
+ }
+ else
+ {
+ Payload = SanitizePayload(payload, type);
+ PrecomputedSummary = null; // Will be computed on first access or dehydration
+ IsDehydrated = false;
+ }
+ }
+
+ ///
+ /// Constructor for creating a dehydrated (trimmed) event.
+ /// Used internally by EventStore for memory optimization.
+ ///
+ private EditorEvent(
+ long sequence,
+ long timestampUnixMs,
+ string type,
+ string targetId,
+ string precomputedSummary)
+ {
+ Sequence = sequence;
+ TimestampUnixMs = timestampUnixMs;
+ Type = type;
+ TargetId = targetId;
+ Payload = null; // Dehydrated - no payload
+ PrecomputedSummary = precomputedSummary;
+ IsDehydrated = true;
+ }
+
+ ///
+ /// Dehydrate this event to save memory.
+ /// - Generates PrecomputedSummary from Payload
+ /// - Sets Payload to null (releasing large objects)
+ /// - Marks event as IsDehydrated
+ ///
+ /// Call this when event becomes "cold" (old but still needed for history).
+ ///
+ public EditorEvent Dehydrate()
+ {
+ if (IsDehydrated)
+ return this; // Already dehydrated
+
+ // Generate summary if not already computed
+ var summary = PrecomputedSummary ?? ComputeSummary();
+
+ // Return new dehydrated event (immutable pattern)
+ return new EditorEvent(
+ Sequence,
+ TimestampUnixMs,
+ Type,
+ TargetId,
+ summary
+ );
+ }
+
+ ///
+ /// Get the precomputed summary, computing it if necessary.
+ /// This is lazy-evaluated to avoid unnecessary computation.
+ ///
+ public string GetSummary()
+ {
+ if (PrecomputedSummary != null)
+ return PrecomputedSummary;
+
+ // Compute and cache (this mutates the object, but it's just a string field)
+ PrecomputedSummary = ComputeSummary();
+ return PrecomputedSummary;
+ }
+
+ ///
+ /// Compute the summary for this event.
+ /// This is called by GetSummary() or Dehydrate().
+ /// Delegates to EventSummarizer for rich summaries.
+ ///
+ private string ComputeSummary()
+ {
+ return MCPForUnity.Editor.ActionTrace.Query.EventSummarizer.Summarize(this);
+ }
+
+ ///
+ /// Validate and sanitize payload values to ensure JSON serializability.
+ /// Converts values to safe types and logs warnings for unsupported types.
+ ///
+ private static Dictionary SanitizePayload(
+ IReadOnlyDictionary payload,
+ string eventType)
+ {
+ var sanitized = new Dictionary();
+
+ foreach (var kvp in payload)
+ {
+ var value = SanitizeValue(kvp.Value, kvp.Key, eventType, 0);
+ if (value != null || kvp.Value == null)
+ {
+ // Only add if not filtered out (null values are allowed)
+ sanitized[kvp.Key] = value;
+ }
+ }
+
+ return sanitized;
+ }
+
+ ///
+ /// Recursively validate and sanitize a single value.
+ /// Returns null for unsupported types (which will be filtered out).
+ ///
+ private static object SanitizeValue(object value, string key, string eventType, int depth)
+ {
+ if (value == null)
+ return null;
+
+ if (depth > MaxSanitizeDepth)
+ {
+ // Depth exceeded: return placeholder to avoid deep structures
+ return "";
+ }
+
+ // Primitive JSON-serializable types
+ if (value is string s)
+ {
+ if (s.Length > MaxStringLength)
+ return s.Substring(0, MaxStringLength) + "...";
+ return s;
+ }
+ if (value is bool)
+ return value;
+
+ // Numeric types - convert to consistent types
+ if (value is int i) return i;
+ if (value is long l) return l;
+ if (value is float f) return f;
+ if (value is double d) return d;
+ if (value is decimal m) return m;
+ if (value is uint ui) return ui;
+ if (value is ulong ul) return ul;
+ if (value is short sh) return sh;
+ if (value is ushort ush) return ush;
+ if (value is byte b) return b;
+ if (value is sbyte sb) return sb;
+ if (value is char c) return c.ToString(); // Char as string
+
+ // Arrays - handle native arrays (int[], string[], etc.)
+ if (value.GetType().IsArray)
+ {
+ return SanitizeArray((Array)value, key, eventType, depth + 1);
+ }
+
+ // Generic collections - use non-generic interface for broader compatibility
+ // This handles List, IEnumerable, HashSet, etc. with any element type
+ if (value is IEnumerable enumerable && !(value is string) && !(value is IDictionary))
+ {
+ return SanitizeEnumerable(enumerable, key, eventType, depth + 1);
+ }
+
+ // Dictionaries - use non-generic interface for broader compatibility
+ // This handles Dictionary with any value type
+ if (value is IDictionary dict)
+ {
+ return SanitizeDictionary(dict, key, eventType, depth + 1);
+ }
+
+ // Unsupported type - log warning and filter out
+ McpLog.Warn(
+ $"[EditorEvent] Unsupported payload type '{value.GetType().Name}' " +
+ $"for key '{key}' in event '{eventType}'. Value will be excluded from payload. " +
+ $"Supported types: string, number, bool, null, array, List, Dictionary.");
+
+ return null; // Filter out unsupported types
+ }
+
+ ///
+ /// Sanitize a native array.
+ ///
+ private static object SanitizeArray(Array array, string key, string eventType, int depth)
+ {
+ var list = new List