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(Math.Min(array.Length, MaxCollectionItems)); + int count = 0; + foreach (var item in array) + { + if (count++ >= MaxCollectionItems) + { + list.Add(""); + break; + } + var sanitized = SanitizeValue(item, key, eventType, depth); + if (sanitized != null || item == null) + { + list.Add(sanitized); + } + } + return list; + } + + /// + /// Sanitize a generic IEnumerable (List, IEnumerable, etc.) + /// Uses non-generic interface to handle any element type. + /// + private static object SanitizeEnumerable(IEnumerable enumerable, string key, string eventType, int depth) + { + var list = new List(MaxCollectionItems); + int count = 0; + foreach (var item in enumerable) + { + if (count++ >= MaxCollectionItems) + { + list.Add(""); + break; + } + var sanitized = SanitizeValue(item, key, eventType, depth); + if (sanitized != null || item == null) + { + list.Add(sanitized); + } + } + return list; + } + + /// + /// Sanitize a generic IDictionary (Dictionary, etc.) + /// Uses non-generic interface to handle any key/value types. + /// Only string keys are supported; other key types are skipped with warning. + /// + private static object SanitizeDictionary(IDictionary dict, string key, string eventType, int depth) + { + var result = new Dictionary(Math.Min(dict.Count, MaxCollectionItems)); + int count = 0; + foreach (DictionaryEntry entry in dict) + { + if (count++ >= MaxCollectionItems) + { + result[""] = "more_items"; + break; + } + + // Only support string keys + if (entry.Key is string stringKey) + { + var sanitizedValue = SanitizeValue(entry.Value, stringKey, eventType, depth); + if (sanitizedValue != null || entry.Value == null) + { + result[stringKey] = sanitizedValue; + } + } + else + { + McpLog.Warn( + $"[EditorEvent] Dictionary key type '{entry.Key?.GetType().Name}' " + + $"is not supported. Only string keys are supported. Key will be skipped."); + } + } + + return result; + } + + // ======================================================================== + // FORBIDDEN FIELDS - Do NOT add these properties to EditorEvent: + // - Importance: Calculate at query time, not stored + // - Source/AI/Human flags: Use Context layer (ContextMapping side-table) + // - SessionId: Use Context layer + // - _ctx: Use Context layer + // These are intentionally omitted to keep the event layer pure. + // ======================================================================== + + public bool Equals(EditorEvent other) + { + if (ReferenceEquals(null, other)) return false; + if (ReferenceEquals(this, other)) return true; + return Sequence == other.Sequence + && TimestampUnixMs == other.TimestampUnixMs + && Type == other.Type + && TargetId == other.TargetId; + } + + public override bool Equals(object obj) + { + return Equals(obj as EditorEvent); + } + + public override int GetHashCode() + { + return HashCode.Combine(Sequence, TimestampUnixMs, Type, TargetId); + } + + public static bool operator ==(EditorEvent left, EditorEvent right) + { + return Equals(left, right); + } + + public static bool operator !=(EditorEvent left, EditorEvent right) + { + return !Equals(left, right); + } + } +} diff --git a/MCPForUnity/Editor/ActionTrace/Core/EventCategory.cs b/MCPForUnity/Editor/ActionTrace/Core/EventCategory.cs new file mode 100644 index 000000000..9fbb4120e --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Core/EventCategory.cs @@ -0,0 +1,21 @@ +namespace MCPForUnity.Editor.ActionTrace.Core +{ + /// + /// Event category. + /// + public enum EventCategory + { + Unknown, + Component, + Property, + GameObject, + Hierarchy, + Selection, + Scene, + Asset, + Script, + Build, + Editor, + System + } +} diff --git a/MCPForUnity/Editor/ActionTrace/Core/EventMetadata.cs b/MCPForUnity/Editor/ActionTrace/Core/EventMetadata.cs new file mode 100644 index 000000000..d74189915 --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Core/EventMetadata.cs @@ -0,0 +1,44 @@ +using System; + +namespace MCPForUnity.Editor.ActionTrace.Core +{ + /// + /// Event metadata. + /// Defines category, importance, summary template, and sampling config for event types. + /// + [Serializable] + public class EventMetadata + { + /// + /// Event category. + /// + public EventCategory Category { get; set; } = EventCategory.Unknown; + + /// + /// Default importance score (0.0 ~ 1.0). + /// + public float DefaultImportance { get; set; } = 0.5f; + + /// + /// Summary template. + /// Supports placeholders: {payload_key}, {type}, {target}, {time} + /// Supports conditionals: {if:key, then} + /// + public string SummaryTemplate { get; set; } + + /// + /// Whether sampling is enabled. + /// + public bool EnableSampling { get; set; } + + /// + /// Sampling mode. + /// + public SamplingMode SamplingMode { get; set; } + + /// + /// Sampling window (milliseconds). + /// + public int SamplingWindow { get; set; } + } +} diff --git a/MCPForUnity/Editor/ActionTrace/Core/EventStore.Context.cs b/MCPForUnity/Editor/ActionTrace/Core/EventStore.Context.cs new file mode 100644 index 000000000..c804efd6c --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Core/EventStore.Context.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MCPForUnity.Editor.ActionTrace.Context; + +namespace MCPForUnity.Editor.ActionTrace.Core +{ + /// + /// Context mapping functionality for EventStore. + /// Manages associations between events and their operation contexts. + /// + public static partial class EventStore + { + /// + /// Calculates the maximum number of context mappings to store. + /// Dynamic: 2x the maxEvents setting (supports multi-agent collaboration). + /// When maxEvents is 5000 (max), this yields 10000 context mappings. + /// + private static int GetMaxContextMappings() + { + var settings = ActionTraceSettings.Instance; + int maxEvents = settings?.Storage.MaxEvents ?? 800; + return maxEvents * 2; // 2x ratio + } + + /// + /// Add a context mapping for an event. + /// Strategy: Multiple mappings allowed for same eventSequence (different contexts). + /// Duplicate detection: Same (eventSequence, contextId) pair will be skipped. + /// Thread-safe - can be called from EventRecorded subscribers. + /// + public static void AddContextMapping(ContextMapping mapping) + { + lock (_queryLock) + { + // Skip duplicate mappings (same eventSequence and contextId) + bool isDuplicate = false; + for (int i = _contextMappings.Count - 1; i >= 0; i--) + { + var existing = _contextMappings[i]; + if (existing.EventSequence == mapping.EventSequence && + existing.ContextId == mapping.ContextId) + { + isDuplicate = true; + break; + } + // Optimization: mappings are ordered by EventSequence + if (existing.EventSequence < mapping.EventSequence) + break; + } + + if (isDuplicate) + return; + + _contextMappings.Add(mapping); + + // Trim oldest mappings if over limit (dynamic based on maxEvents setting) + int maxContextMappings = GetMaxContextMappings(); + if (_contextMappings.Count > maxContextMappings) + { + int removeCount = _contextMappings.Count - maxContextMappings; + _contextMappings.RemoveRange(0, removeCount); + } + } + + // Mark dirty and schedule deferred save + _isDirty = true; + ScheduleSave(); + } + + /// + /// Remove all context mappings for a specific context ID. + /// + public static void RemoveContextMappings(Guid contextId) + { + lock (_queryLock) + { + _contextMappings.RemoveAll(m => m.ContextId == contextId); + } + // Mark dirty and schedule deferred save + _isDirty = true; + ScheduleSave(); + } + + /// + /// Get the number of stored context mappings. + /// + public static int ContextMappingCount + { + get + { + lock (_queryLock) + { + return _contextMappings.Count; + } + } + } + + /// + /// Query events with their context associations. + /// Returns a tuple of (Event, Context) where Context may be null. + /// + public static IReadOnlyList<(EditorEvent Event, ContextMapping Context)> QueryWithContext( + int limit = 50, + long? sinceSequence = null) + { + List eventsSnapshot; + List mappingsSnapshot; + + lock (_queryLock) + { + int eventCount = _events.Count; + if (eventCount == 0) + { + return Array.Empty<(EditorEvent, ContextMapping)>(); + } + + // Base window: tail portion for recent queries + int copyCount = Math.Min(eventCount, limit + (limit / 10) + 10); + int startIndex = eventCount - copyCount; + + // If sinceSequence is specified, ensure we don't miss matching events + if (sinceSequence.HasValue) + { + int firstMatchIndex = -1; + for (int i = eventCount - 1; i >= 0; i--) + { + if (_events[i].Sequence > sinceSequence.Value) + { + firstMatchIndex = i; + } + else if (firstMatchIndex >= 0) + { + break; + } + } + + if (firstMatchIndex >= 0 && firstMatchIndex < startIndex) + { + startIndex = firstMatchIndex; + copyCount = eventCount - startIndex; + } + } + + eventsSnapshot = new List(copyCount); + for (int i = startIndex; i < eventCount; i++) + { + eventsSnapshot.Add(_events[i]); + } + + // For mappings, copy all (usually much smaller than events) + mappingsSnapshot = new List(_contextMappings); + } + + // Build lookup dictionary outside lock + var mappingBySequence = mappingsSnapshot + .GroupBy(m => m.EventSequence) + .ToDictionary(g => g.Key, g => g.FirstOrDefault()); + + // Query and join outside lock + var query = eventsSnapshot.AsEnumerable(); + + if (sinceSequence.HasValue) + { + query = query.Where(e => e.Sequence > sinceSequence.Value); + } + + var results = query + .OrderByDescending(e => e.Sequence) + .Take(limit) + .Select(e => + { + mappingBySequence.TryGetValue(e.Sequence, out var mapping); + return (Event: e, Context: mapping); + }) + .ToList(); + + return results; + } + } +} diff --git a/MCPForUnity/Editor/ActionTrace/Core/EventStore.Diagnostics.cs b/MCPForUnity/Editor/ActionTrace/Core/EventStore.Diagnostics.cs new file mode 100644 index 000000000..9c6710f93 --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Core/EventStore.Diagnostics.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using MCPForUnity.Editor.ActionTrace.Semantics; + +namespace MCPForUnity.Editor.ActionTrace.Core +{ + /// + /// Diagnostic and memory management functionality for EventStore. + /// + public static partial class EventStore + { + /// + /// Dehydrate old events (beyond hotEventCount) to save memory. + /// This is called automatically by Record(). + /// + private static void DehydrateOldEvents(int hotEventCount) + { + // Find events that need dehydration (not already dehydrated and beyond hot count) + for (int i = 0; i < _events.Count - hotEventCount; i++) + { + var evt = _events[i]; + if (evt != null && !evt.IsDehydrated && evt.Payload != null) + { + // Dehydrate the event (creates new instance with Payload = null) + _events[i] = evt.Dehydrate(); + } + } + } + + /// + /// Get diagnostic information about memory usage. + /// Useful for monitoring and debugging memory issues. + /// + public static string GetMemoryDiagnostics() + { + lock (_queryLock) + { + var settings = ActionTraceSettings.Instance; + int hotEventCount = settings?.Storage.HotEventCount ?? 100; + int maxEvents = settings?.Storage.MaxEvents ?? 800; + + int totalEvents = _events.Count; + int hotEvents = Math.Min(totalEvents, hotEventCount); + int coldEvents = Math.Max(0, totalEvents - hotEventCount); + + int hydratedCount = 0; + int dehydratedCount = 0; + long estimatedPayloadBytes = 0; + + foreach (var evt in _events) + { + if (evt.IsDehydrated) + dehydratedCount++; + else if (evt.Payload != null) + { + hydratedCount++; + estimatedPayloadBytes += EstimatePayloadSize(evt.Payload); + } + } + + // Estimate dehydrated events size (~100 bytes each) + long dehydratedBytes = dehydratedCount * 100; + long totalEstimatedBytes = estimatedPayloadBytes + dehydratedBytes; + double totalEstimatedMB = totalEstimatedBytes / (1024.0 * 1024.0); + + return $"EventStore Memory Diagnostics:\n" + + $" Total Events: {totalEvents}/{maxEvents}\n" + + $" Hot Events (full payload): {hotEvents}\n" + + $" Cold Events (dehydrated): {coldEvents}\n" + + $" Hydrated: {hydratedCount}\n" + + $" Dehydrated: {dehydratedCount}\n" + + $" Estimated Payload Memory: {estimatedPayloadBytes / 1024} KB\n" + + $" Total Estimated Memory: {totalEstimatedMB:F2} MB"; + } + } + + /// + /// Estimate the size of a payload in bytes. + /// This is a rough approximation for diagnostics. + /// + private static long EstimatePayloadSize(IReadOnlyDictionary payload) + { + if (payload == null) return 0; + + long size = 0; + foreach (var kvp in payload) + { + // Key string (assume average 20 chars) + size += kvp.Key.Length * 2; + + // Value + if (kvp.Value is string str) + size += str.Length * 2; + else if (kvp.Value is int) + size += 4; + else if (kvp.Value is long) + size += 8; + else if (kvp.Value is double) + size += 8; + else if (kvp.Value is bool) + size += 1; + else if (kvp.Value is IDictionary dict) + size += dict.Count * 100; + else if (kvp.Value is System.Collections.ICollection list) + size += list.Count * 50; + else + size += 50; + } + + return size; + } + } +} diff --git a/MCPForUnity/Editor/ActionTrace/Core/EventStore.Merging.cs b/MCPForUnity/Editor/ActionTrace/Core/EventStore.Merging.cs new file mode 100644 index 000000000..b84418b97 --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Core/EventStore.Merging.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using MCPForUnity.Editor.ActionTrace.Semantics; +using UnityEditor; + +namespace MCPForUnity.Editor.ActionTrace.Core +{ + /// + /// Event merging (deduplication) functionality for EventStore. + /// High-frequency events within a short time window are merged to reduce noise. + /// + public static partial class EventStore + { + // Event types that are eligible for merging (high-frequency, noisy events) + private static readonly HashSet MergeableEventTypes = new() + { + EventTypes.PropertyModified, + EventTypes.SelectionPropertyModified, + EventTypes.HierarchyChanged, + EventTypes.SelectionChanged + }; + + /// + /// Checks if the given event should be merged with the last recorded event. + /// Merging criteria: + /// - Same event type (and type is mergeable) + /// - Same target ID + /// - Same property path (for property modification events) + /// - Within merge time window + /// + private static bool ShouldMergeWithLast(EditorEvent evt) + { + if (_lastRecordedEvent == null) + return false; + + var settings = ActionTraceSettings.Instance; + int mergeWindowMs = settings?.Merging.MergeWindowMs ?? 100; + + // Time window check + long timeDelta = evt.TimestampUnixMs - _lastRecordedTime; + if (timeDelta > mergeWindowMs || timeDelta < 0) + return false; + + // Type check: must be the same mergeable type + if (evt.Type != _lastRecordedEvent.Type) + return false; + + if (!MergeableEventTypes.Contains(evt.Type)) + return false; + + // Target check: must be the same target + if (evt.TargetId != _lastRecordedEvent.TargetId) + return false; + + // Property path check: for property modification events, must be same property + string currentPropertyPath = GetPropertyPathFromPayload(evt.Payload); + string lastPropertyPath = GetPropertyPathFromPayload(_lastRecordedEvent.Payload); + if (!string.Equals(currentPropertyPath, lastPropertyPath, StringComparison.Ordinal)) + return false; + + return true; + } + + /// + /// Merges the new event with the last recorded event. + /// Updates the last event's timestamp and end_value (if applicable). + /// IMPORTANT: This method must only be called while holding _queryLock. + /// + /// The new event to merge (without sequence number) + /// The event with assigned sequence number (for updating _lastRecordedEvent) + private static void MergeWithLastEventLocked(EditorEvent evt, EditorEvent evtWithSequence) + { + if (_lastRecordedEvent == null) + return; + + // Update timestamp to reflect the most recent activity + _lastRecordedTime = evt.TimestampUnixMs; + + // Update the last event in the list + // CRITICAL: Always update _events[lastEventIndex] to maintain consistency with _lastRecordedEvent + int lastEventIndex = _events.Count - 1; + if (lastEventIndex < 0) + return; + + // For events with payload, update with merged payload + if (evt.Payload != null && _lastRecordedEvent.Payload != null) + { + var newPayload = new Dictionary(_lastRecordedEvent.Payload); + + // Update end_value with the new value + if (evt.Payload.TryGetValue("end_value", out var newValue)) + { + newPayload["end_value"] = newValue; + } + + // Update timestamp in payload + newPayload["timestamp"] = evt.TimestampUnixMs; + + // Add merge_count to track how many events were merged + int mergeCount = 1; + if (_lastRecordedEvent.Payload.TryGetValue("merge_count", out var existingCount)) + { + mergeCount = (int)existingCount + 1; + } + newPayload["merge_count"] = mergeCount; + + // Update the last event with merged payload + _events[lastEventIndex] = new EditorEvent( + sequence: _lastRecordedEvent.Sequence, + timestampUnixMs: evt.TimestampUnixMs, + type: _lastRecordedEvent.Type, + targetId: _lastRecordedEvent.TargetId, + payload: newPayload + ); + } + else + { + // For dehydrated events or non-property-modification events, + // update timestamp and keep existing payload + _events[lastEventIndex] = new EditorEvent( + sequence: _lastRecordedEvent.Sequence, + timestampUnixMs: evt.TimestampUnixMs, + type: _lastRecordedEvent.Type, + targetId: _lastRecordedEvent.TargetId, + payload: _lastRecordedEvent.Payload + ); + } + + // Update _lastRecordedEvent to reference the merged event from the list + _lastRecordedEvent = _events[lastEventIndex]; + + // Schedule save since we modified the last event + _isDirty = true; + ScheduleSave(); + } + + /// + /// Extracts the property path from an event payload. + /// Used for merge detection of property modification events. + /// + private static string GetPropertyPathFromPayload(IReadOnlyDictionary payload) + { + if (payload == null) + return null; + + if (payload.TryGetValue("property_path", out var propertyPath)) + return propertyPath as string; + + return null; + } + } +} diff --git a/MCPForUnity/Editor/ActionTrace/Core/EventStore.Persistence.cs b/MCPForUnity/Editor/ActionTrace/Core/EventStore.Persistence.cs new file mode 100644 index 000000000..b31b058db --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Core/EventStore.Persistence.cs @@ -0,0 +1,206 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MCPForUnity.Editor.ActionTrace.Context; +using MCPForUnity.Editor.ActionTrace.Semantics; +using MCPForUnity.Editor.Helpers; +using UnityEditor; + +namespace MCPForUnity.Editor.ActionTrace.Core +{ + /// + /// Persistence functionality for EventStore. + /// Handles domain reload survival and deferred save scheduling. + /// + public static partial class EventStore + { + private const string StateKey = "timeline_events"; + private const int CurrentSchemaVersion = 4; + + private static bool _isLoaded; + private static bool _saveScheduled; // Prevents duplicate delayCall registrations + + + /// + /// Persistent state schema for EventStore. + /// + private class EventStoreState + { + public int SchemaVersion { get; set; } = CurrentSchemaVersion; + public long SequenceCounter { get; set; } + public List Events { get; set; } + public List ContextMappings { get; set; } + } + + /// + /// Schedule a deferred save via delayCall. + /// Multiple rapid calls result in a single save (coalesced). + /// Thread-safe: uses lock to protect _saveScheduled flag. + /// + private static void ScheduleSave() + { + // Use lock to prevent race conditions with _saveScheduled + lock (_queryLock) + { + // Only schedule if not already scheduled (prevents callback queue bloat) + if (_saveScheduled) + return; + + _saveScheduled = true; + } + + // Use delayCall to coalesce multiple saves into one + EditorApplication.delayCall += () => + { + bool wasDirty; + lock (_queryLock) + { + _saveScheduled = false; + wasDirty = _isDirty; + if (_isDirty) + { + _isDirty = false; + } + } + + // Perform save outside lock to avoid holding lock during I/O + if (wasDirty) + { + SaveToStorage(); + } + }; + } + + /// + /// Clears all pending notifications and scheduled saves. + /// Call this when shutting down or reloading domains to prevent delayCall leaks. + /// + public static void ClearPendingOperations() + { + lock (_pendingNotifications) + { + _pendingNotifications.Clear(); + _notifyScheduled = false; + } + _saveScheduled = false; + _lastDehydratedCount = -1; // Reset dehydration optimization marker + } + + /// + /// Load events from persistent storage. + /// Called once during static initialization. + /// + private static void LoadFromStorage() + { + if (_isLoaded) return; + + try + { + var state = McpJobStateStore.LoadState(StateKey); + if (state != null) + { + // Schema version check for migration support + // Note: We assume forward compatibility - newer data can be loaded by older code + if (state.SchemaVersion > CurrentSchemaVersion) + { + McpLog.Warn( + $"[EventStore] Loading data from newer schema version {state.SchemaVersion} " + + $"(current is {CurrentSchemaVersion}). Assuming forward compatibility."); + } + else if (state.SchemaVersion < CurrentSchemaVersion) + { + McpLog.Info( + $"[EventStore] Data from schema version {state.SchemaVersion} will be " + + $"resaved with current version {CurrentSchemaVersion}."); + } + + _sequenceCounter = state.SequenceCounter; + _events.Clear(); + if (state.Events != null) + { + _events.AddRange(state.Events); + } + _contextMappings.Clear(); + if (state.ContextMappings != null) + { + _contextMappings.AddRange(state.ContextMappings); + } + + // CRITICAL: Trim to MaxEvents limit after loading + TrimToMaxEventsLimit(); + } + } + catch (Exception ex) + { + McpLog.Error($"[EventStore] Failed to load from storage: {ex.Message}\n{ex.StackTrace}"); + } + finally + { + _isLoaded = true; + } + } + + /// + /// Trims events and context mappings if they exceed the hard limit. + /// Uses a two-tier limit to avoid aggressive trimming. + /// + private static void TrimToMaxEventsLimit() + { + var settings = ActionTraceSettings.Instance; + int maxEvents = settings?.Storage.MaxEvents ?? 800; + int hardLimit = maxEvents * 2; // 2x buffer + int maxContextMappings = GetMaxContextMappings(); + + lock (_queryLock) + { + // Only trim if exceeding hard limit, not soft limit + if (_events.Count > hardLimit) + { + int removeCount = _events.Count - maxEvents; + var removedSequences = new HashSet(); + for (int i = 0; i < removeCount; i++) + { + removedSequences.Add(_events[i].Sequence); + } + _events.RemoveRange(0, removeCount); + + // Cascade delete context mappings + _contextMappings.RemoveAll(m => removedSequences.Contains(m.EventSequence)); + + McpLog.Info($"[EventStore] Trimmed {removeCount} old events " + + $"(was {_events.Count + removeCount}, now {maxEvents}, hard limit was {hardLimit})"); + } + + // Trim context mappings if over limit (dynamic based on maxEvents setting) + if (_contextMappings.Count > maxContextMappings) + { + int removeCount = _contextMappings.Count - maxContextMappings; + _contextMappings.RemoveRange(0, removeCount); + } + } + } + + /// + /// Save events to persistent storage. + /// + private static void SaveToStorage() + { + try + { + var state = new EventStoreState + { + SchemaVersion = CurrentSchemaVersion, + SequenceCounter = _sequenceCounter, + Events = _events.ToList(), + ContextMappings = _contextMappings.ToList() + }; + McpJobStateStore.SaveState(StateKey, state); + } + catch (Exception ex) + { + McpLog.Error($"[EventStore] Failed to save to storage: {ex.Message}\n{ex.StackTrace}"); + } + } + + } +} diff --git a/MCPForUnity/Editor/ActionTrace/Core/EventStore.cs b/MCPForUnity/Editor/ActionTrace/Core/EventStore.cs new file mode 100644 index 000000000..2808ee1f9 --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Core/EventStore.cs @@ -0,0 +1,317 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using MCPForUnity.Editor.ActionTrace.Context; +using MCPForUnity.Editor.ActionTrace.Semantics; +using UnityEditor; + +namespace MCPForUnity.Editor.ActionTrace.Core +{ + /// + /// Thread-safe event store for editor events. + /// + /// Threading model: + /// - Writes: Main thread only + /// - Reads: Any thread, uses lock for snapshot pattern + /// - Sequence generation: Uses Interlocked.Increment for atomicity + /// + /// Persistence: Uses McpJobStateStore for domain reload survival. + /// Save strategy: Deferred persistence with dirty flag + delayCall coalescing. + /// + /// Memory optimization (Pruning): + /// - Hot events (latest 100): Full payload retained + /// - Cold events (older than 100): Automatically dehydrated (payload = null) + /// + /// Event merging (Deduplication): + /// - High-frequency events are merged within a short time window to reduce noise + /// + /// Code organization: Split into multiple partial class files: + /// - EventStore.cs (this file): Core API (Record, Query, Clear, Count) + /// - EventStore.Merging.cs: Event merging/deduplication logic + /// - EventStore.Persistence.cs: Save/load, domain reload survival + /// - EventStore.Context.cs: Context mapping management + /// - EventStore.Diagnostics.cs: Memory diagnostics and dehydration + /// + public static partial class EventStore + { + // Core state + private static readonly List _events = new(); + private static readonly List _contextMappings = new(); + private static readonly object _queryLock = new(); + private static long _sequenceCounter; + + // Batch notification: accumulate pending events and notify in single delayCall + private static readonly List _pendingNotifications = new(); + private static bool _notifyScheduled; + + // Main thread detection: Kept for legacy/debugging purposes only + private static readonly int MainThreadId = Thread.CurrentThread.ManagedThreadId; + + // Fields shared with other partial class files + private static EditorEvent _lastRecordedEvent; + private static long _lastRecordedTime; + private static bool _isDirty; + private static int _lastDehydratedCount = -1; // Optimizes dehydration trigger + + /// + /// Event raised when a new event is recorded. + /// Used by ContextTrace to create associations. + /// + public static event Action EventRecorded; + + static EventStore() + { + LoadFromStorage(); + } + + /// + /// Record a new event. Must be called from main thread. + /// + /// Returns: + /// - New sequence number for newly recorded events + /// - Existing sequence number when events are merged + /// - -1 when event is rejected by filters + /// + /// Note: Set ActionTraceSettings.BypassImportanceFilter = true to record all events + /// regardless of importance score (useful for complete timeline view). + /// + public static long Record(EditorEvent @event) + { + var settings = ActionTraceSettings.Instance; + + // Apply disabled event types filter (hard filter, cannot be bypassed) + if (settings != null && IsEventTypeDisabled(@event.Type, settings.Filtering.DisabledEventTypes)) + { + return -1; + } + + // Apply importance filter at store level (unless bypassed in Settings) + if (settings != null && !settings.Filtering.BypassImportanceFilter) + { + float importance = DefaultEventScorer.Instance.Score(@event); + if (importance < settings.Filtering.MinImportanceForRecording) + { + return -1; + } + } + + long newSequence = Interlocked.Increment(ref _sequenceCounter); + + var evtWithSequence = new EditorEvent( + sequence: newSequence, + timestampUnixMs: @event.TimestampUnixMs, + type: @event.Type, + targetId: @event.TargetId, + payload: @event.Payload + ); + + int hotEventCount = settings?.Storage.HotEventCount ?? 100; + int maxEvents = settings?.Storage.MaxEvents ?? 800; + + lock (_queryLock) + { + // Check if this event should be merged with the last one + if (settings?.Merging.EnableEventMerging != false && ShouldMergeWithLast(@event)) + { + MergeWithLastEventLocked(@event, evtWithSequence); + return _lastRecordedEvent.Sequence; + } + + _events.Add(evtWithSequence); + + // Update merge tracking AFTER merge check and add to prevent self-merge + _lastRecordedEvent = evtWithSequence; + _lastRecordedTime = @event.TimestampUnixMs; + + // Auto-dehydrate old events (optimized: only when count changes) + if (_events.Count > hotEventCount && _events.Count != _lastDehydratedCount) + { + DehydrateOldEvents(hotEventCount); + _lastDehydratedCount = _events.Count; + } + + // Trim oldest events if over limit + if (_events.Count > maxEvents) + { + int removeCount = _events.Count - maxEvents; + var removedSequences = new HashSet(); + for (int i = 0; i < removeCount; i++) + { + removedSequences.Add(_events[i].Sequence); + } + _events.RemoveRange(0, removeCount); + _contextMappings.RemoveAll(m => removedSequences.Contains(m.EventSequence)); + } + + // Mark dirty inside lock for thread safety + _isDirty = true; + } + + ScheduleSave(); + + // Batch notification + lock (_pendingNotifications) + { + _pendingNotifications.Add(evtWithSequence); + } + ScheduleNotify(); + + return evtWithSequence.Sequence; + } + + /// + /// Query events with optional filtering. + /// Thread-safe - can be called from any thread. + /// + public static IReadOnlyList Query(int limit = 50, long? sinceSequence = null) + { + List snapshot; + + lock (_queryLock) + { + int count = _events.Count; + if (count == 0) + return Array.Empty(); + + // Base window: tail portion for recent queries + int copyCount = Math.Min(count, limit + (limit / 10) + 10); + int startIndex = count - copyCount; + + // If sinceSequence is specified, ensure we don't miss matching events + if (sinceSequence.HasValue) + { + int firstMatchIndex = -1; + for (int i = count - 1; i >= 0; i--) + { + if (_events[i].Sequence > sinceSequence.Value) + firstMatchIndex = i; + else if (firstMatchIndex >= 0) + break; + } + + if (firstMatchIndex >= 0 && firstMatchIndex < startIndex) + { + startIndex = firstMatchIndex; + copyCount = count - startIndex; + } + } + + snapshot = new List(copyCount); + for (int i = startIndex; i < count; i++) + { + snapshot.Add(_events[i]); + } + } + + var query = snapshot.AsEnumerable(); + + if (sinceSequence.HasValue) + { + query = query.Where(e => e.Sequence > sinceSequence.Value); + } + + return query.OrderByDescending(e => e.Sequence).Take(limit).ToList(); + } + + /// + /// Get the current sequence counter value. + /// + public static long CurrentSequence => _sequenceCounter; + + /// + /// Get total event count. + /// + public static int Count + { + get + { + lock (_queryLock) + { + return _events.Count; + } + } + } + + /// + /// Clear all events and context mappings. + /// WARNING: This is destructive and cannot be undone. + /// + public static void Clear() + { + lock (_queryLock) + { + _events.Clear(); + _contextMappings.Clear(); + _sequenceCounter = 0; + } + + // Reset merge tracking and pending notifications + _lastRecordedEvent = null; + _lastRecordedTime = 0; + _lastDehydratedCount = -1; + lock (_pendingNotifications) + { + _pendingNotifications.Clear(); + _notifyScheduled = false; + } + + SaveToStorage(); + } + + /// + /// Schedule batch notification via delayCall. + /// Multiple rapid events result in a single notification batch. + /// + private static void ScheduleNotify() + { + lock (_pendingNotifications) + { + if (_notifyScheduled) + return; + _notifyScheduled = true; + } + + EditorApplication.delayCall += DrainPendingNotifications; + } + + /// + /// Drain all pending notifications and invoke EventRecorded for each. + /// + private static void DrainPendingNotifications() + { + List toNotify; + lock (_pendingNotifications) + { + _notifyScheduled = false; + + if (_pendingNotifications.Count == 0) + return; + + toNotify = new List(_pendingNotifications); + _pendingNotifications.Clear(); + } + + foreach (var evt in toNotify) + { + EventRecorded?.Invoke(evt); + } + } + + /// + /// Check if an event type is disabled in settings. + /// + private static bool IsEventTypeDisabled(string eventType, string[] disabledTypes) + { + if (disabledTypes == null || disabledTypes.Length == 0) + return false; + + foreach (string disabled in disabledTypes) + { + if (string.Equals(eventType, disabled, StringComparison.Ordinal)) + return true; + } + return false; + } + } +} diff --git a/MCPForUnity/Editor/ActionTrace/Core/EventTypes.cs b/MCPForUnity/Editor/ActionTrace/Core/EventTypes.cs new file mode 100644 index 000000000..3640b6489 --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Core/EventTypes.cs @@ -0,0 +1,273 @@ +using System; +using System.Collections.Generic; + +// ========== Add New Event Checklist ========== +// +// 1. Add event constant above: +// public const string YourNewEvent = "YourNewEvent"; +// +// 2. Add configuration in Metadata._metadata: +// [YourNewEvent] = new EventMetadata { ... } +// +// 3. If special scoring logic is needed, add to DefaultEventScorer.GetPayloadAdjustment() +// +// 4. If special summary format is needed, use conditional template or handle separately +// +// Done! No need to modify other files. + +namespace MCPForUnity.Editor.ActionTrace.Core +{ + /// + /// Centralized constant definitions for ActionTrace event types. + /// Provides type-safe event type names and reduces string literal usage. + /// + /// Usage: + /// EventTypes.ComponentAdded // instead of "ComponentAdded" + /// EventTypes.Metadata.Get(ComponentAdded) // get event metadata + /// + public static class EventTypes + { + // Component events + public const string ComponentAdded = "ComponentAdded"; + public const string ComponentRemoved = "ComponentRemoved"; + + // Property events (P0: Property-Level Tracking) + public const string PropertyModified = "PropertyModified"; + public const string SelectionPropertyModified = "SelectionPropertyModified"; + + // GameObject events + public const string GameObjectCreated = "GameObjectCreated"; + public const string GameObjectDestroyed = "GameObjectDestroyed"; + + // Hierarchy events + public const string HierarchyChanged = "HierarchyChanged"; + + // Selection events (P2.3: Selection Tracking) + public const string SelectionChanged = "SelectionChanged"; + + // Play mode events + public const string PlayModeChanged = "PlayModeChanged"; + + // Scene events + public const string SceneSaving = "SceneSaving"; + public const string SceneSaved = "SceneSaved"; + public const string SceneOpened = "SceneOpened"; + public const string NewSceneCreated = "NewSceneCreated"; + + // Asset events + public const string AssetImported = "AssetImported"; + public const string AssetCreated = "AssetCreated"; + public const string AssetDeleted = "AssetDeleted"; + public const string AssetMoved = "AssetMoved"; + public const string AssetModified = "AssetModified"; + + // Script events + public const string ScriptCompiled = "ScriptCompiled"; + public const string ScriptCompilationFailed = "ScriptCompilationFailed"; + + // Build events + public const string BuildStarted = "BuildStarted"; + public const string BuildCompleted = "BuildCompleted"; + public const string BuildFailed = "BuildFailed"; + + // ========== Event Metadata Configuration ========== + + /// + /// Event metadata configuration. + /// Centrally manages default importance, summary templates, sampling config, etc. for each event type. + /// + /// When adding new events, simply add configuration here. No need to modify other files. + /// + public static class Metadata + { + private static readonly Dictionary _metadata = new(StringComparer.Ordinal) + { + // ========== Critical (1.0) ========== + [BuildFailed] = new EventMetadata + { + Category = EventCategory.Build, + DefaultImportance = 1.0f, + SummaryTemplate = "Build failed: {platform}", + }, + [ScriptCompilationFailed] = new EventMetadata + { + Category = EventCategory.Script, + DefaultImportance = 1.0f, + SummaryTemplate = "Script compilation failed: {error_count} errors", + }, + ["AINote"] = new EventMetadata + { + Category = EventCategory.System, + DefaultImportance = 1.0f, + SummaryTemplate = "AI Note{if:agent_id, ({agent_id})}: {note}", + }, + + // ========== High (0.7-0.9) ========== + [BuildStarted] = new EventMetadata + { + Category = EventCategory.Build, + DefaultImportance = 0.9f, + SummaryTemplate = "Build started: {platform}", + }, + [BuildCompleted] = new EventMetadata + { + Category = EventCategory.Build, + DefaultImportance = 1.0f, + SummaryTemplate = "Build completed: {platform}", + }, + [SceneSaved] = new EventMetadata + { + Category = EventCategory.Scene, + DefaultImportance = 0.8f, + SummaryTemplate = "Scene saved: {scene_name} ({target_id})", + }, + [AssetDeleted] = new EventMetadata + { + Category = EventCategory.Asset, + DefaultImportance = 0.8f, + SummaryTemplate = "Deleted asset: {path} ({target_id})", + }, + [SceneOpened] = new EventMetadata + { + Category = EventCategory.Scene, + DefaultImportance = 0.7f, + SummaryTemplate = "Opened scene: {scene_name} ({target_id})", + }, + [ComponentRemoved] = new EventMetadata + { + Category = EventCategory.Component, + DefaultImportance = 0.7f, + SummaryTemplate = "Removed {component_type} from {game_object} (GameObject:{target_id})", + }, + [SelectionPropertyModified] = new EventMetadata + { + Category = EventCategory.Property, + DefaultImportance = 0.7f, + SummaryTemplate = "Changed {component_type}.{property_path}: {start_value} → {end_value} (selected, GameObject:{target_id})", + }, + + // ========== Medium (0.4-0.6) ========== + [ComponentAdded] = new EventMetadata + { + Category = EventCategory.Component, + DefaultImportance = 0.6f, + SummaryTemplate = "Added {component_type} to {game_object} (GameObject:{target_id})", + }, + [PropertyModified] = new EventMetadata + { + Category = EventCategory.Property, + DefaultImportance = 0.6f, + SummaryTemplate = "Changed {component_type}.{property_path}: {start_value} → {end_value} (GameObject:{target_id})", + }, + [NewSceneCreated] = new EventMetadata + { + Category = EventCategory.Scene, + DefaultImportance = 0.6f, + SummaryTemplate = "New scene created ({target_id})", + }, + [GameObjectDestroyed] = new EventMetadata + { + Category = EventCategory.GameObject, + DefaultImportance = 0.6f, + SummaryTemplate = "Destroyed: {name} (GameObject:{target_id})", + }, + [SceneSaving] = new EventMetadata + { + Category = EventCategory.Scene, + DefaultImportance = 0.5f, + SummaryTemplate = "Saving scene: {scene_name} ({target_id})", + }, + [GameObjectCreated] = new EventMetadata + { + Category = EventCategory.GameObject, + DefaultImportance = 0.5f, + SummaryTemplate = "Created: {name} (GameObject:{target_id})", + }, + [AssetImported] = new EventMetadata + { + Category = EventCategory.Asset, + DefaultImportance = 0.5f, + SummaryTemplate = "Imported {asset_type}: {path} ({target_id})", + }, + [AssetCreated] = new EventMetadata + { + Category = EventCategory.Asset, + DefaultImportance = 0.5f, + SummaryTemplate = "Created {asset_type}: {path} ({target_id})", + }, + [AssetModified] = new EventMetadata + { + Category = EventCategory.Asset, + DefaultImportance = 0.4f, + SummaryTemplate = "Modified {asset_type}: {path} ({target_id})", + }, + [ScriptCompiled] = new EventMetadata + { + Category = EventCategory.Script, + DefaultImportance = 0.4f, + SummaryTemplate = "Scripts compiled: {script_count} files ({duration_ms}ms)", + }, + + // ========== Low (0.1-0.3) ========== + [AssetMoved] = new EventMetadata + { + Category = EventCategory.Asset, + DefaultImportance = 0.3f, + SummaryTemplate = "Moved {from_path} → {to_path} ({target_id})", + }, + [PlayModeChanged] = new EventMetadata + { + Category = EventCategory.Editor, + DefaultImportance = 0.3f, + SummaryTemplate = "Play mode: {state}", + }, + [HierarchyChanged] = new EventMetadata + { + Category = EventCategory.Hierarchy, + DefaultImportance = 0.2f, + SummaryTemplate = "Hierarchy changed", + EnableSampling = true, + SamplingMode = SamplingMode.Throttle, + SamplingWindow = 1000, + }, + [SelectionChanged] = new EventMetadata + { + Category = EventCategory.Selection, + DefaultImportance = 0.1f, + SummaryTemplate = "Selection changed ({target_id})", + }, + }; + + /// + /// Get metadata for an event type. + /// Returns default metadata if not found. + /// + public static EventMetadata Get(string eventType) + { + return _metadata.TryGetValue(eventType, out var meta) ? meta : Default; + } + + /// + /// Set or update metadata for an event type. + /// Use for runtime dynamic configuration. + /// + public static void Set(string eventType, EventMetadata metadata) + { + _metadata[eventType] = metadata; + } + + /// + /// Default metadata for unconfigured event types. + /// + public static EventMetadata Default { get; } = new EventMetadata + { + Category = EventCategory.Unknown, + DefaultImportance = 0.1f, + SummaryTemplate = "{type} on {target}", + }; + } + + + } +} + diff --git a/MCPForUnity/Editor/ActionTrace/Core/Presets/ActionTracePreset.cs b/MCPForUnity/Editor/ActionTrace/Core/Presets/ActionTracePreset.cs new file mode 100644 index 000000000..a36ca43cf --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Core/Presets/ActionTracePreset.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace MCPForUnity.Editor.ActionTrace.Core +{ + /// + /// Preset configurations for ActionTrace settings. + /// Each preset provides a balanced configuration for specific use cases. + /// + [Serializable] + public sealed class ActionTracePreset + { + public string Name; + public string Description; + public float MinImportance; + public int MaxEvents; + public int HotEventCount; + public bool EnableEventMerging; + public int MergeWindowMs; + public int TransactionWindowMs; + + public static readonly ActionTracePreset DebugAll = new() + { + Name = "Debug (All Events)", + Description = "Record all events for debugging and complete traceability. Higher memory usage.", + MinImportance = 0.0f, + MaxEvents = 2000, + HotEventCount = 400, + EnableEventMerging = false, + MergeWindowMs = 0, + TransactionWindowMs = 5000 + }; + + public static readonly ActionTracePreset Standard = new() + { + Name = "Standard", + Description = "Standard configuration balancing performance and traceability. Suitable for daily development.", + MinImportance = 0.4f, + MaxEvents = 800, + HotEventCount = 150, + EnableEventMerging = true, + MergeWindowMs = 100, + TransactionWindowMs = 2000 + }; + + public static readonly ActionTracePreset Lean = new() + { + Name = "Lean (Minimal)", + Description = "Minimal configuration, only records high importance events. Lowest memory usage.", + MinImportance = 0.7f, + MaxEvents = 300, + HotEventCount = 50, + EnableEventMerging = true, + MergeWindowMs = 50, + TransactionWindowMs = 1000 + }; + + public static readonly ActionTracePreset AIFocused = new() + { + Name = "AI Assistant", + Description = "AI assistant optimized configuration. Focuses on asset changes and build events.", + MinImportance = 0.5f, + MaxEvents = 1000, + HotEventCount = 200, + EnableEventMerging = true, + MergeWindowMs = 100, + TransactionWindowMs = 3000 + }; + + public static readonly ActionTracePreset Realtime = new() + { + Name = "Realtime", + Description = "Realtime collaboration configuration. Minimal latency, high-frequency event sampling.", + MinImportance = 0.3f, + MaxEvents = 600, + HotEventCount = 100, + EnableEventMerging = true, + MergeWindowMs = 50, + TransactionWindowMs = 1500 + }; + + public static readonly ActionTracePreset Performance = new() + { + Name = "Performance", + Description = "Performance-first configuration. Minimal memory overhead, only critical events.", + MinImportance = 0.6f, + MaxEvents = 200, + HotEventCount = 30, + EnableEventMerging = true, + MergeWindowMs = 50, + TransactionWindowMs = 1000 + }; + + public static readonly List AllPresets = new() + { + DebugAll, Standard, Lean, AIFocused, Realtime, Performance + }; + } +} diff --git a/MCPForUnity/Editor/ActionTrace/Core/SamplingMode.cs b/MCPForUnity/Editor/ActionTrace/Core/SamplingMode.cs new file mode 100644 index 000000000..f593c9d7c --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Core/SamplingMode.cs @@ -0,0 +1,20 @@ +namespace MCPForUnity.Editor.ActionTrace.Core +{ + /// + /// Sampling mode. + /// + public enum SamplingMode + { + /// No sampling, record all events + None, + + /// Throttle - only record first event within window + Throttle, + + /// Debounce - only record last event within window + Debounce, + + /// DebounceByKey - debounce per unique key + DebounceByKey + } +} diff --git a/MCPForUnity/Editor/ActionTrace/Descriptors/ComponentEventDescriptor.cs b/MCPForUnity/Editor/ActionTrace/Descriptors/ComponentEventDescriptor.cs new file mode 100644 index 000000000..cc3629a84 --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Descriptors/ComponentEventDescriptor.cs @@ -0,0 +1,187 @@ +using System; +using System.Collections.Generic; +using MCPForUnity.Editor.ActionTrace.Core; + +namespace MCPForUnity.Editor.ActionTrace.Descriptors +{ + /// + /// Descriptor for ComponentAdded events. + /// + public sealed class ComponentAddedDescriptor : EventDescriptorBase + { + public override string EventType => EventTypes.ComponentAdded; + + public override string Summarize(EditorEvent evt) + { + var componentType = GetString(evt, "component_type", "Component"); + var targetName = GetString(evt, "game_object", GetTargetName(evt)); + return $"Added {componentType} component to {targetName}"; + } + + public override Dictionary ExtractPayload(Dictionary rawPayload) + { + if (rawPayload == null) + return new Dictionary(); + + return new Dictionary + { + ["component_type"] = rawPayload.GetValueOrDefault("component_type", "Unknown"), + ["game_object"] = rawPayload.GetValueOrDefault("game_object", "Unknown") + }; + } + } + + /// + /// Descriptor for HierarchyChanged events. + /// + public sealed class HierarchyChangedDescriptor : EventDescriptorBase + { + public override string EventType => EventTypes.HierarchyChanged; + + public override string Summarize(EditorEvent evt) + { + return "Scene hierarchy changed"; + } + + public override Dictionary ExtractPayload(Dictionary rawPayload) + { + return new Dictionary + { + ["timestamp"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + }; + } + } + + /// + /// Descriptor for AssetImported events. + /// + public sealed class AssetImportedDescriptor : EventDescriptorBase + { + public override string EventType => EventTypes.AssetImported; + + public override string Summarize(EditorEvent evt) + { + var path = GetString(evt, "path", "Unknown"); + var assetType = GetString(evt, "asset_type", "asset"); + + // More specific summaries for known asset types + if (assetType == "script") + return $"Imported script: {path}"; + if (assetType == "scene") + return $"Imported scene: {path}"; + if (assetType == "prefab") + return $"Imported prefab: {path}"; + if (assetType == "texture") + return $"Imported texture: {path}"; + if (assetType == "audio") + return $"Imported audio: {path}"; + + return $"Imported {assetType}: {path}"; + } + + public override Dictionary ExtractPayload(Dictionary rawPayload) + { + if (rawPayload == null) + return new Dictionary(); + + var path = rawPayload.GetValueOrDefault("path", string.Empty)?.ToString() ?? string.Empty; + var extension = System.IO.Path.GetExtension(path); + + return new Dictionary + { + ["path"] = path, + ["extension"] = extension, + ["asset_type"] = rawPayload.GetValueOrDefault("asset_type", DetectAssetType(extension)) + }; + } + + private static string DetectAssetType(string extension) + { + if (string.IsNullOrEmpty(extension)) + return "unknown"; + + return extension.ToLower() switch + { + ".cs" => "script", + ".unity" => "scene", + ".prefab" => "prefab", + ".mat" => "material", + ".png" or ".jpg" or ".jpeg" => "texture", + ".wav" or ".mp3" or ".ogg" => "audio", + ".fbx" => "model", + ".anim" => "animation", + ".controller" => "animator_controller", + _ => "unknown" + }; + } + } + + /// + /// Descriptor for PlayModeChanged events. + /// + public sealed class PlayModeChangedDescriptor : EventDescriptorBase + { + public override string EventType => EventTypes.PlayModeChanged; + + public override string Summarize(EditorEvent evt) + { + var state = GetString(evt, "state", "Unknown"); + return $"Play mode changed to {state}"; + } + + public override Dictionary ExtractPayload(Dictionary rawPayload) + { + return new Dictionary + { + ["state"] = rawPayload?.GetValueOrDefault("state", "Unknown") ?? "Unknown" + }; + } + } + + /// + /// Descriptor for SceneSaving events. + /// + public sealed class SceneSavingDescriptor : EventDescriptorBase + { + public override string EventType => EventTypes.SceneSaving; + + public override string Summarize(EditorEvent evt) + { + var sceneName = GetString(evt, "scene_name", "Scene"); + return $"Saving scene: {sceneName}"; + } + + public override Dictionary ExtractPayload(Dictionary rawPayload) + { + return new Dictionary + { + ["scene_name"] = rawPayload?.GetValueOrDefault("scene_name", "Unknown") ?? "Unknown", + ["path"] = rawPayload?.GetValueOrDefault("path", string.Empty) ?? string.Empty + }; + } + } + + /// + /// Descriptor for SceneOpened events. + /// + public sealed class SceneOpenedDescriptor : EventDescriptorBase + { + public override string EventType => EventTypes.SceneOpened; + + public override string Summarize(EditorEvent evt) + { + var sceneName = GetString(evt, "scene_name", "Scene"); + return $"Opened scene: {sceneName}"; + } + + public override Dictionary ExtractPayload(Dictionary rawPayload) + { + return new Dictionary + { + ["scene_name"] = rawPayload?.GetValueOrDefault("scene_name", "Unknown") ?? "Unknown", + ["path"] = rawPayload?.GetValueOrDefault("path", string.Empty) ?? string.Empty, + ["mode"] = rawPayload?.GetValueOrDefault("mode", "Unknown") ?? "Unknown" + }; + } + } +} diff --git a/MCPForUnity/Editor/ActionTrace/Descriptors/IEventDescriptor.cs b/MCPForUnity/Editor/ActionTrace/Descriptors/IEventDescriptor.cs new file mode 100644 index 000000000..e96de0ac0 --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Descriptors/IEventDescriptor.cs @@ -0,0 +1,87 @@ +using System.Collections.Generic; +using MCPForUnity.Editor.ActionTrace.Core; + +namespace MCPForUnity.Editor.ActionTrace.Descriptors +{ + /// + /// Interface for event descriptors. + /// Descriptors encapsulate event type metadata, payload schema, and summarization logic. + /// This decouples EventSummarizer from payload structure changes. + /// + public interface IEventDescriptor + { + /// + /// The event type this descriptor handles. + /// Must match one of the constants in EventTypes. + /// + string EventType { get; } + + /// + /// Generate a human-readable summary for this event type. + /// + string Summarize(EditorEvent evt); + + /// + /// Validate and extract payload fields. + /// Returns a sanitized payload dictionary with all required fields. + /// Can be used to validate payload structure before recording. + /// + Dictionary ExtractPayload(Dictionary rawPayload); + } + + /// + /// Base class for event descriptors with common functionality. + /// + public abstract class EventDescriptorBase : IEventDescriptor + { + public abstract string EventType { get; } + + public abstract string Summarize(EditorEvent evt); + + public virtual Dictionary ExtractPayload(Dictionary rawPayload) + { + // Default implementation: pass through payload as-is + return rawPayload != null + ? new Dictionary(rawPayload) + : new Dictionary(); + } + + /// + /// Helper method to safely get a string value from payload. + /// + protected string GetString(EditorEvent evt, string key, string defaultValue = "") + { + if (evt.Payload.TryGetValue(key, out var value)) + { + return value?.ToString() ?? defaultValue; + } + return defaultValue; + } + + /// + /// Helper method to safely get a target name from payload. + /// Tries multiple common keys like "name", "game_object", "scene_name", etc. + /// + protected string GetTargetName(EditorEvent evt) + { + // Try common name fields + if (evt.Payload.TryGetValue("name", out var name)) + return name.ToString(); + + if (evt.Payload.TryGetValue("game_object", out var goName)) + return goName.ToString(); + + if (evt.Payload.TryGetValue("scene_name", out var sceneName)) + return sceneName.ToString(); + + if (evt.Payload.TryGetValue("component_type", out var componentType)) + return componentType.ToString(); + + if (evt.Payload.TryGetValue("path", out var path)) + return path.ToString(); + + // Fall back to target ID + return evt.TargetId; + } + } +} diff --git a/MCPForUnity/Editor/ActionTrace/Helpers/ActionTraceHelper.cs b/MCPForUnity/Editor/ActionTrace/Helpers/ActionTraceHelper.cs new file mode 100644 index 000000000..fec74791b --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Helpers/ActionTraceHelper.cs @@ -0,0 +1,82 @@ +using System; +using UnityEngine; +using UnityEditor; + +namespace MCPForUnity.Editor.ActionTrace.Helpers +{ + /// + /// Helper utilities for ActionTrace feature. + /// + /// Centralized common formatting and conversion methods + /// to avoid code duplication across ActionTrace components. + /// + public static class ActionTraceHelper + { + /// + /// Formats a tool name for display. + /// Converts snake_case to Title Case. + /// + /// Examples: + /// - "manage_gameobject" → "Manage GameObject" + /// - "add_ActionTrace_note" → "Add ActionTrace Note" + /// - "get_ActionTrace" → "Get ActionTrace" + /// + /// Used in: + /// - TransactionAggregator (summary generation) + /// - UndoGroupManager (Undo group names) + /// + public static string FormatToolName(string toolName) + { + if (string.IsNullOrEmpty(toolName)) + return "AI Operation"; + + // Convert snake_case to Title Case with spaces + // Examples: "manage_gameobject" → "Manage GameObject" + return System.Text.RegularExpressions.Regex.Replace( + toolName, + "(^|_)([a-z])", + match => + { + // If starts with underscore, replace underscore with space and uppercase + // If at start, just uppercase + return match.Groups[1].Value == "_" + ? " " + match.Groups[2].Value.ToUpper() + : match.Groups[2].Value.ToUpper(); + } + ); + } + + /// + /// Formats duration for display. + /// Converts milliseconds to human-readable "X.Xs" format. + /// + /// Examples: + /// - 500 → "0.5s" + /// - 1500 → "1.5s" + /// - 2340 → "2.3s" + /// + /// Used in: + /// - TransactionAggregator (AtomicOperation.DurationMs display) + /// + public static string FormatDuration(long milliseconds) + { + return $"{milliseconds / 1000.0:F1}s"; + } + + /// + /// Formats duration from a timestamp range. + /// + /// Parameters: + /// startMs: Start timestamp in milliseconds + /// endMs: End timestamp in milliseconds + /// + /// Returns: + /// Human-readable duration string (e.g., "2.3s") + /// + public static string FormatDurationFromRange(long startMs, long endMs) + { + return FormatDuration(endMs - startMs); + } + + } +} diff --git a/MCPForUnity/Editor/ActionTrace/Helpers/GameObjectTrackingHelper.cs b/MCPForUnity/Editor/ActionTrace/Helpers/GameObjectTrackingHelper.cs new file mode 100644 index 000000000..9cb49e865 --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Helpers/GameObjectTrackingHelper.cs @@ -0,0 +1,195 @@ +using System; +using System.Collections.Generic; +using UnityEngine; +using UnityEditor; +using UnityEngine.Assertions; +using MCPForUnity.Editor.ActionTrace.Core; + +namespace MCPForUnity.Editor.ActionTrace.Helpers +{ + /// + /// Helper for tracking GameObject creation and destruction. + /// Monitors instance IDs to detect changes in the scene hierarchy. + /// + /// Performance optimizations: + /// - Uses HashSet for O(1) lookup instead of List.Contains O(n) + /// - Pre-allocates capacity to reduce resizing + /// - Manual loops instead of LINQ to avoid GC allocations + /// + /// Thread safety: All methods must be called from the main thread. + /// This is enforced via debug assertions in development builds. + /// + internal static class GameObjectTrackingHelper + { + // Use HashSet for O(1) Contains() instead of List's O(n) + private static readonly HashSet _previousInstanceIds = new(256); + private static bool _hasInitialized; + + // Cache for the main thread ID to validate thread safety + private static readonly int MainThreadId = System.Threading.Thread.CurrentThread.ManagedThreadId; + + /// + /// Initializes tracking by capturing all existing GameObject instance IDs. + /// Should be called once after domain reload or scene load. + /// Must be called from the main thread. + /// + public static void InitializeTracking() + { + AssertMainThread(); + + if (_hasInitialized) return; + + _previousInstanceIds.Clear(); + // Reserve capacity for typical scene sizes + _previousInstanceIds.EnsureCapacity(256); + + try + { + // Manual loop instead of LINQ for performance + GameObject[] allObjects = GameObject.FindObjectsOfType(true); + foreach (var go in allObjects) + { + if (go != null) + _previousInstanceIds.Add(go.GetInstanceID()); + } + } + catch (Exception ex) + { + // Log error instead of silent swallow for debugging + Debug.LogError($"[ActionTrace] Failed to initialize GameObject tracking: {ex.Message}"); + } + + _hasInitialized = true; + } + + /// + /// Detects newly created GameObjects by comparing with previous instance IDs. + /// Returns a list of (GameObject, wasNewlyCreated) tuples. + /// Must be called from the main thread. + /// + public static List<(GameObject obj, bool isNew)> DetectChanges() + { + AssertMainThread(); + + if (!_hasInitialized) + { + InitializeTracking(); + return new List<(GameObject, bool)>(0); + } + + var results = new List<(GameObject, bool)>(64); + var currentIds = new HashSet(256); + + try + { + GameObject[] currentObjects = GameObject.FindObjectsOfType(true); + + // First pass: collect current IDs and detect new objects + foreach (var go in currentObjects) + { + if (go == null) continue; + + int id = go.GetInstanceID(); + currentIds.Add(id); + + // HashSet.Contains() is O(1) vs List.Contains() O(n) + bool isNew = !_previousInstanceIds.Contains(id); + results.Add((go, isNew)); + } + + // Update tracking: swap hash sets to avoid allocation + _previousInstanceIds.Clear(); + foreach (int id in currentIds) + { + _previousInstanceIds.Add(id); + } + } + catch (Exception ex) + { + Debug.LogError($"[ActionTrace] Failed to detect GameObject changes: {ex.Message}"); + } + + return results; + } + + /// + /// Gets instance IDs of GameObjects that were destroyed since last check. + /// Must be called from the main thread. + /// + public static List GetDestroyedInstanceIds() + { + AssertMainThread(); + + if (!_hasInitialized) + return new List(0); + + var destroyed = new List(8); + var currentIds = new HashSet(256); + + try + { + // Collect current IDs + GameObject[] currentObjects = GameObject.FindObjectsOfType(true); + foreach (var go in currentObjects) + { + if (go != null) + currentIds.Add(go.GetInstanceID()); + } + + // Find IDs that were in previous but not in current + foreach (int id in _previousInstanceIds) + { + if (!currentIds.Contains(id)) + destroyed.Add(id); + } + + // Update tracking for next call + _previousInstanceIds.Clear(); + foreach (int id in currentIds) + { + _previousInstanceIds.Add(id); + } + } + catch (Exception ex) + { + Debug.LogError($"[ActionTrace] Failed to get destroyed instance IDs: {ex.Message}"); + } + + return destroyed; + } + + /// + /// Resets tracking state. + /// Call this when loading a new scene or entering play mode. + /// Must be called from the main thread. + /// + public static void Reset() + { + AssertMainThread(); + _previousInstanceIds.Clear(); + _hasInitialized = false; + } + + /// + /// Gets the current count of tracked instance IDs. + /// Useful for debugging and monitoring. + /// + public static int TrackedCount => _previousInstanceIds.Count; + + /// + /// Debug assertion to ensure methods are called from main thread. + /// Only active in development builds. + /// + [System.Diagnostics.Conditional("UNITY_ASSERTIONS")] + private static void AssertMainThread() + { + if (System.Threading.Thread.CurrentThread.ManagedThreadId != MainThreadId) + { + throw new InvalidOperationException( + $"[ActionTrace] GameObjectTrackingHelper must be called from the main thread. " + + $"Current thread: {System.Threading.Thread.CurrentThread.ManagedThreadId}, " + + $"Main thread: {MainThreadId}"); + } + } + } +} diff --git a/MCPForUnity/Editor/ActionTrace/Helpers/PropertyEventPayloadBuilder.cs b/MCPForUnity/Editor/ActionTrace/Helpers/PropertyEventPayloadBuilder.cs new file mode 100644 index 000000000..4a3177b95 --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Helpers/PropertyEventPayloadBuilder.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; + +namespace MCPForUnity.Editor.ActionTrace.Helpers +{ + /// + /// Unified payload builder for property modification events. + /// Ensures consistent payload structure across different trackers. + /// + public static class PropertyEventPayloadBuilder + { + /// + /// Builds the base payload for a property modification event. + /// + /// Name of the modified object + /// Type of the modified component + /// Serialized property path + /// JSON formatted start value + /// JSON formatted end value + /// Type name of the property value + /// Number of merged changes + public static Dictionary BuildPropertyModifiedPayload( + string targetName, + string componentType, + string propertyPath, + string startValue, + string endValue, + string valueType, + int changeCount = 1) + { + return new Dictionary + { + ["target_name"] = targetName, + ["component_type"] = componentType, + ["property_path"] = propertyPath, + ["start_value"] = startValue, + ["end_value"] = endValue, + ["value_type"] = valueType, + ["change_count"] = changeCount + }; + } + + /// + /// Builds a selection property modified event payload with selection context. + /// + public static Dictionary BuildSelectionPropertyModifiedPayload( + string targetName, + string componentType, + string propertyPath, + string startValue, + string endValue, + string valueType, + string selectionName, + string selectionType, + string selectionPath) + { + return new Dictionary + { + ["target_name"] = targetName, + ["component_type"] = componentType, + ["property_path"] = propertyPath, + ["start_value"] = startValue, + ["end_value"] = endValue, + ["value_type"] = valueType, + ["selection_context"] = new Dictionary + { + ["selection_name"] = selectionName, + ["selection_type"] = selectionType, + ["selection_path"] = selectionPath ?? string.Empty + } + }; + } + } +} diff --git a/MCPForUnity/Editor/ActionTrace/Helpers/PropertyFormatter.cs b/MCPForUnity/Editor/ActionTrace/Helpers/PropertyFormatter.cs new file mode 100644 index 000000000..a5f9ebe41 --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Helpers/PropertyFormatter.cs @@ -0,0 +1,88 @@ +using System; +using System.IO; +using UnityEngine; +using MCPForUnity.Editor.Helpers; + +namespace MCPForUnity.Editor.ActionTrace.Helpers +{ + /// + /// Unified property formatting utilities for ActionTrace events. + /// Eliminates code duplication between PropertyChangeTracker and SelectionPropertyTracker. + /// + public static class PropertyFormatter + { + /// + /// Checks if a property is a Unity internal property that should be ignored. + /// + public static bool IsInternalProperty(string propertyPath) + { + if (string.IsNullOrEmpty(propertyPath)) + return false; + + return propertyPath.StartsWith("m_Script") || + propertyPath.StartsWith("m_EditorClassIdentifier") || + propertyPath.StartsWith("m_ObjectHideFlags"); + } + + /// + /// Formats a property value for JSON storage. + /// Uses UnityJsonSerializer.Instance for proper Unity type serialization. + /// + public static string FormatPropertyValue(object value) + { + if (value == null) + return "null"; + + try + { + using (var writer = new StringWriter()) + { + UnityJsonSerializer.Instance.Serialize(writer, value); + return writer.ToString(); + } + } + catch (Exception) + { + return value.ToString(); + } + } + + /// + /// Gets the type name of a property value for the event payload. + /// Uses friendly names for common Unity types. + /// + public static string GetPropertyTypeName(object value) + { + if (value == null) + return "null"; + + Type type = value.GetType(); + + // Number types + if (type == typeof(float) || type == typeof(int) || type == typeof(double)) + return "Number"; + if (type == typeof(bool)) + return "Boolean"; + if (type == typeof(string)) + return "String"; + + // Unity types + if (type == typeof(Vector2) || type == typeof(Vector3) || type == typeof(Vector4)) + return type.Name; + if (type == typeof(Quaternion)) + return "Quaternion"; + if (type == typeof(Color)) + return "Color"; + if (type == typeof(Rect)) + return "Rect"; + if (type == typeof(Bounds)) + return "Bounds"; + if (type == typeof(Vector2Int)) + return "Vector2Int"; + if (type == typeof(Vector3Int)) + return "Vector3Int"; + + return type.Name; + } + } +} diff --git a/MCPForUnity/Editor/ActionTrace/Helpers/UndoReflectionHelper.cs b/MCPForUnity/Editor/ActionTrace/Helpers/UndoReflectionHelper.cs new file mode 100644 index 000000000..5c1762bd2 --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Helpers/UndoReflectionHelper.cs @@ -0,0 +1,112 @@ +using System; +using UnityEditor; + +namespace MCPForUnity.Editor.ActionTrace.Helpers +{ + /// + /// Shared reflection helpers for extracting data from Unity's UndoPropertyModification. + /// This class centralizes reflection logic that was duplicated across PropertyChangeTracker and SelectionPropertyTracker. + /// + public static class UndoReflectionHelper + { + /// + /// Generic reflection helper to extract nested values from UndoPropertyModification. + /// Traverses dot-separated property paths like "propertyModification.target". + /// + /// Handles both Property and Field access, providing flexibility for Unity's internal structure variations. + /// + /// The root object to start traversal from (typically UndoPropertyModification) + /// Dot-separated path to desired value (e.g., "propertyModification.target") + /// The extracted value, or null if any part of path cannot be resolved + public static object GetNestedValue(object root, string path) + { + if (root == null || string.IsNullOrEmpty(path)) + return null; + + var parts = path.Split('.'); + object current = root; + + foreach (var part in parts) + { + if (current == null) return null; + + // Try property first (for currentValue, previousValue) + var prop = current.GetType().GetProperty(part); + if (prop != null) + { + current = prop.GetValue(current); + continue; + } + + // Try field (for propertyModification, target, value, etc.) + var field = current.GetType().GetField(part); + if (field != null) + { + current = field.GetValue(current); + continue; + } + + return null; + } + + return current; + } + + /// + /// Extracts target object from an UndoPropertyModification. + /// The target is UnityEngine.Object being modified (e.g., a Component or GameObject). + /// + public static UnityEngine.Object GetTarget(UndoPropertyModification undoMod) + { + // Try direct 'currentValue.target' path + var result = GetNestedValue(undoMod, "currentValue.target"); + if (result is UnityEngine.Object obj) return obj; + + // Fallback to 'previousValue.target' + result = GetNestedValue(undoMod, "previousValue.target"); + if (result is UnityEngine.Object obj2) return obj2; + + return null; + } + + /// + /// Extracts property path from an UndoPropertyModification. + /// The property path identifies which property was modified (e.g., "m_Intensity"). + /// + public static string GetPropertyPath(UndoPropertyModification undoMod) + { + var result = GetNestedValue(undoMod, "currentValue.propertyPath"); + if (result != null) return result as string; + + result = GetNestedValue(undoMod, "previousValue.propertyPath"); + return result as string; + } + + /// + /// Extracts current (new) value from an UndoPropertyModification. + /// This is value after modification was applied. + /// + public static object GetCurrentValue(UndoPropertyModification undoMod) + { + // Try direct 'currentValue.value' path + var result = GetNestedValue(undoMod, "currentValue.value"); + if (result != null) return result; + + return GetNestedValue(undoMod, "currentValue"); + } + + /// + /// Extracts previous (old) value from an UndoPropertyModification. + /// This is value before modification was applied. + /// + public static object GetPreviousValue(UndoPropertyModification undoMod) + { + // Try 'previousValue.value' (nested structure) first - matches GetCurrentValue pattern + var result = GetNestedValue(undoMod, "previousValue.value"); + if (result != null) return result; + + // Fallback to direct 'previousValue' property + return GetNestedValue(undoMod, "previousValue"); + } + } +} diff --git a/MCPForUnity/Editor/ActionTrace/Integration/ManageAssetBridge.cs b/MCPForUnity/Editor/ActionTrace/Integration/ManageAssetBridge.cs new file mode 100644 index 000000000..776216961 --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Integration/ManageAssetBridge.cs @@ -0,0 +1,87 @@ +using System; +using UnityEditor; +using MCPForUnity.Editor.Tools; +using MCPForUnity.Editor.Helpers; +using System.Collections.Generic; + +namespace MCPForUnity.Editor.ActionTrace.Integration +{ + /// + /// Low-coupling bridge between ManageAsset and ActionTrace systems. + /// + /// This class subscribes to ManageAsset's events and forwards them to ActionTraceEventEmitter. + /// The bridge pattern ensures: + /// - ManageAsset has no direct dependency on ActionTrace + /// - ActionTrace can be enabled/disabled without affecting ManageAsset + /// - Single point of integration for easy maintenance + /// + /// Location: ActionTrace/Integration/ (separate folder for cross-system bridges) + /// + [InitializeOnLoad] + internal static class ManageAssetBridge + { + static ManageAssetBridge() + { + // Subscribe to ManageAsset events + // Events can only be subscribed to; null checks are not needed for subscription + ManageAsset.OnAssetModified += OnAssetModifiedHandler; + ManageAsset.OnAssetCreated += OnAssetCreatedHandler; + ManageAsset.OnAssetDeleted += OnAssetDeletedHandler; + } + + /// + /// Forward asset modification events to ActionTrace. + /// + private static void OnAssetModifiedHandler(string assetPath, string assetType, IReadOnlyDictionary changes) + { + try + { + Capture.ActionTraceEventEmitter.EmitAssetModified(assetPath, assetType, changes); + } + catch (Exception ex) + { + McpLog.Warn($"[ManageAssetBridge] Failed to record asset modification: {ex.Message}"); + } + } + + /// + /// Forward asset creation events to ActionTrace. + /// + private static void OnAssetCreatedHandler(string assetPath, string assetType) + { + try + { + Capture.ActionTraceEventEmitter.EmitAssetCreated(assetPath, assetType); + } + catch (Exception ex) + { + McpLog.Warn($"[ManageAssetBridge] Failed to record asset creation: {ex.Message}"); + } + } + + /// + /// Forward asset deletion events to ActionTrace. + /// + private static void OnAssetDeletedHandler(string assetPath, string assetType) + { + try + { + Capture.ActionTraceEventEmitter.EmitAssetDeleted(assetPath, assetType); + } + catch (Exception ex) + { + McpLog.Warn($"[ManageAssetBridge] Failed to record asset deletion: {ex.Message}"); + } + } + + /// + /// Unsubscribe from all events (useful for testing or cleanup). + /// + internal static void Disconnect() + { + ManageAsset.OnAssetModified -= OnAssetModifiedHandler; + ManageAsset.OnAssetCreated -= OnAssetCreatedHandler; + ManageAsset.OnAssetDeleted -= OnAssetDeletedHandler; + } + } +} diff --git a/MCPForUnity/Editor/ActionTrace/Query/ActionTraceQuery.cs b/MCPForUnity/Editor/ActionTrace/Query/ActionTraceQuery.cs new file mode 100644 index 000000000..e2f85b45a --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Query/ActionTraceQuery.cs @@ -0,0 +1,322 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MCPForUnity.Editor.ActionTrace.Core; +using MCPForUnity.Editor.ActionTrace.Semantics; +using UnityEngine; + +namespace MCPForUnity.Editor.ActionTrace.Query +{ + /// + /// Query engine that projects events with semantic information. + /// All semantic data (importance, category, intent) is computed at query time + /// and does not modify the original events. + /// + public sealed class ActionTraceQuery + { + // Static color caches to avoid repeated Color allocations during UI rendering + private static readonly Dictionary EventTypeColors = new() + { + ["ComponentAdded"] = new Color(0.3f, 0.8f, 0.3f), + ["PropertyModified"] = new Color(0.3f, 0.6f, 0.8f), + ["SelectionPropertyModified"] = new Color(0.5f, 0.8f, 0.9f), + ["GameObjectCreated"] = new Color(0.8f, 0.3f, 0.8f), + ["HierarchyChanged"] = new Color(0.8f, 0.8f, 0.3f), + ["AINote"] = new Color(0.3f, 0.8f, 0.8f), + }; + + private static readonly Dictionary ImportanceColors = new() + { + ["critical"] = new Color(1f, 0.3f, 0.3f, 0.1f), + ["high"] = new Color(1f, 0.6f, 0f, 0.08f), + ["medium"] = new Color(1f, 1f, 0.3f, 0.06f), + ["low"] = null, + }; + + private static readonly Dictionary ImportanceBadgeColors = new() + { + ["critical"] = new Color(0.8f, 0.2f, 0.2f), + ["high"] = new Color(1f, 0.5f, 0f), + ["medium"] = new Color(1f, 0.8f, 0.2f), + ["low"] = new Color(0.5f, 0.5f, 0.5f), + }; + + private readonly IEventScorer _scorer; + private readonly IEventCategorizer _categorizer; + private readonly IIntentInferrer _inferrer; + + /// + /// Create a new ActionTraceQuery with optional custom semantic components. + /// If null, default implementations are used. + /// + public ActionTraceQuery( + IEventScorer scorer = null, + IEventCategorizer categorizer = null, + IIntentInferrer inferrer = null) + { + _scorer = scorer ?? new DefaultEventScorer(); + _categorizer = categorizer ?? new DefaultCategorizer(); + _inferrer = inferrer ?? new DefaultIntentInferrer(); + } + + /// + /// Project events with computed semantic information. + /// Returns ActionTraceViewItem objects containing the original event plus + /// dynamically calculated importance, category, and intent. + /// + public IReadOnlyList Project(IReadOnlyList events) + { + if (events == null || events.Count == 0) + return Array.Empty(); + + var result = new ActionTraceViewItem[events.Count]; + + for (int i = 0; i < events.Count; i++) + { + var evt = events[i]; + + // Compute importance score + var score = _scorer.Score(evt); + + // Categorize the score + var category = _categorizer.Categorize(score); + + // Compute context window (5 events before and after current event) for intent inference + int contextWindow = 5; + int contextStart = Math.Max(0, i - contextWindow); + int contextEnd = Math.Min(events.Count, i + contextWindow + 1); + int contextLength = contextEnd - contextStart; + + EditorEvent[] surrounding = null; + if (contextLength > 0) + { + surrounding = new EditorEvent[contextLength]; + + // Performance: EventStore queries are usually in chronological order (but Query returns may be descending). + // Detect order in O(1) (compare first/last sequence) and fill surrounding in chronological order if needed + bool isDescending = events.Count > 1 && events[0].Sequence > events[events.Count - 1].Sequence; + + if (!isDescending) + { + for (int j = 0; j < contextLength; j++) + { + surrounding[j] = events[contextStart + j]; + } + } + else + { + // events are descending (newest first), need to build surrounding in ascending order (oldest->newest) + // Fill from contextEnd-1 down to contextStart to produce ascending window + for (int j = 0; j < contextLength; j++) + { + surrounding[j] = events[contextEnd - 1 - j]; + } + } + } + + // Use surrounding parameter for intent inference (in chronological order) + var intent = _inferrer.Infer(evt, surrounding); + + // Use EditorEvent's GetSummary() method, which automatically handles dehydrated events + var displaySummary = evt.GetSummary(); + var displaySummaryLower = (displaySummary ?? string.Empty).ToLowerInvariant(); + var displayTargetIdLower = (evt.TargetId ?? string.Empty).ToLowerInvariant(); + + // Format as local time including date: MM-dd HH:mm + var localTime = DateTimeOffset.FromUnixTimeMilliseconds(evt.TimestampUnixMs).ToLocalTime(); + var displayTime = localTime.ToString("MM-dd HH:mm"); + var displaySequence = evt.Sequence.ToString(); + + // Precompute colors + var typeColor = GetEventTypeColor(evt.Type); + var importanceColor = GetImportanceColor(category); + var importanceBadgeColor = GetImportanceBadgeColor(category); + + result[i] = new ActionTraceViewItem + { + Event = evt, + ImportanceScore = score, + ImportanceCategory = category, + InferredIntent = intent, + // Set display cache + DisplaySummary = displaySummary, + DisplaySummaryLower = displaySummaryLower, + DisplayTargetIdLower = displayTargetIdLower, + DisplayTime = displayTime, + DisplaySequence = displaySequence, + TypeColor = typeColor, + ImportanceColor = importanceColor, + ImportanceBadgeColor = importanceBadgeColor + }; + } + + return result; + } + + /// + /// Project events with context associations. + /// Overload for QueryWithContext results. + /// + public IReadOnlyList ProjectWithContext( + IReadOnlyList<(EditorEvent Event, Context.ContextMapping Context)> eventsWithContext) + { + if (eventsWithContext == null || eventsWithContext.Count == 0) + return Array.Empty(); + + var result = new ActionTraceViewItem[eventsWithContext.Count]; + + for (int i = 0; i < eventsWithContext.Count; i++) + { + var (evt, ctx) = eventsWithContext[i]; + + var score = _scorer.Score(evt); + var category = _categorizer.Categorize(score); + + // Use simple inference to avoid List allocation + var intent = _inferrer.Infer(evt, surrounding: null); + + // Use EditorEvent's GetSummary() method, which automatically handles dehydrated events + var displaySummary = evt.GetSummary(); + var displaySummaryLower = (displaySummary ?? string.Empty).ToLowerInvariant(); + var displayTargetIdLower = (evt.TargetId ?? string.Empty).ToLowerInvariant(); + + // Format as local time including date: MM-dd HH:mm + var localTime = DateTimeOffset.FromUnixTimeMilliseconds(evt.TimestampUnixMs).ToLocalTime(); + var displayTime = localTime.ToString("MM-dd HH:mm"); + var displaySequence = evt.Sequence.ToString(); + + // Precompute colors + var typeColor = GetEventTypeColor(evt.Type); + var importanceColor = GetImportanceColor(category); + var importanceBadgeColor = GetImportanceBadgeColor(category); + + result[i] = new ActionTraceViewItem + { + Event = evt, + Context = ctx, + ImportanceScore = score, + ImportanceCategory = category, + InferredIntent = intent, + // Set display cache + DisplaySummary = displaySummary, + DisplaySummaryLower = displaySummaryLower, + DisplayTargetIdLower = displayTargetIdLower, + DisplayTime = displayTime, + DisplaySequence = displaySequence, + TypeColor = typeColor, + ImportanceColor = importanceColor, + ImportanceBadgeColor = importanceBadgeColor + }; + } + + return result; + } + + /// + /// Get event type color for display. + /// Uses cached values to avoid repeated allocations. + /// + private static Color GetEventTypeColor(string eventType) + { + return EventTypeColors.TryGetValue(eventType, out var color) ? color : Color.gray; + } + + /// + /// Get importance background color (nullable). + /// Uses cached values to avoid repeated allocations. + /// + private static Color? GetImportanceColor(string category) + { + return ImportanceColors.TryGetValue(category, out var color) ? color : null; + } + + /// + /// Get importance badge color. + /// Uses cached values to avoid repeated allocations. + /// + private static Color GetImportanceBadgeColor(string category) + { + return ImportanceBadgeColors.TryGetValue(category, out var color) ? color : Color.gray; + } + + /// + /// A view of an event with projected semantic information. + /// This is a computed projection, not stored data. + /// + /// Performance optimization: All display strings are precomputed at projection time + /// to avoid repeated allocations in OnGUI. + /// + public sealed class ActionTraceViewItem + { + /// + /// The original immutable event. + /// + public EditorEvent Event { get; set; } + + /// + /// Optional context association (may be null). + /// + public Context.ContextMapping Context { get; set; } + + /// + /// Computed importance score (0.0 to 1.0). + /// Higher values indicate more important events. + /// + public float ImportanceScore { get; set; } + + /// + /// Category label derived from importance score. + /// Values: "critical", "high", "medium", "low" + /// + public string ImportanceCategory { get; set; } + + /// + /// Inferred user intent or purpose. + /// May be null if intent cannot be determined. + /// + public string InferredIntent { get; set; } + + // ========== Display cache (avoid repeated allocations in OnGUI) ========== + + /// + /// Precomputed event summary for display. + /// + public string DisplaySummary { get; set; } + + /// + /// Precomputed summary in lowercase for search filtering. + /// + public string DisplaySummaryLower { get; set; } + + /// + /// Precomputed target ID in lowercase for search filtering. + /// + public string DisplayTargetIdLower { get; set; } + + /// + /// Precomputed formatted time (HH:mm:ss). + /// + public string DisplayTime { get; set; } + + /// + /// Precomputed sequence number as string. + /// + public string DisplaySequence { get; set; } + + /// + /// Precomputed event type color (avoid switch during rendering). + /// + public Color TypeColor { get; set; } + + /// + /// Precomputed importance background color. + /// + public Color? ImportanceColor { get; set; } + + /// + /// Precomputed importance badge color. + /// + public Color ImportanceBadgeColor { get; set; } + } + } +} diff --git a/MCPForUnity/Editor/ActionTrace/Query/ContextCompressor.cs b/MCPForUnity/Editor/ActionTrace/Query/ContextCompressor.cs new file mode 100644 index 000000000..5f9ccae11 --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Query/ContextCompressor.cs @@ -0,0 +1,441 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MCPForUnity.Editor.ActionTrace.Core; +using MCPForUnity.Editor.ActionTrace.Semantics; + +namespace MCPForUnity.Editor.ActionTrace.Query +{ + /// + /// Configuration for context compression behavior. + /// + [Serializable] + public sealed class ContextCompressionConfig + { + /// + /// Minimum importance threshold for keeping full event payload. + /// Events below this will be dehydrated (payload = null). + /// + public float MinImportanceForFullPayload = 0.5f; + + /// + /// Always keep full payload for critical events (score >= this value). + /// + public float CriticalEventThreshold = 0.9f; + + /// + /// Always keep events with these types (regardless of importance). + /// + public string[] AlwaysKeepEventTypes = new[] + { + EventTypes.BuildFailed, + EventTypes.ScriptCompilationFailed, + EventTypes.SceneSaved, + "AINote" + }; + + /// + /// Time window for "recent events" summary (minutes). + /// Recent events are always kept with full payload. + /// + public int RecentEventsWindowMinutes = 10; + + /// + /// Maximum number of events to keep in compressed context. + /// + public int MaxCompressedEvents = 200; + + /// + /// Target compression ratio (0.0 - 1.0). + /// 1.0 = no compression, 0.1 = aggressive compression. + /// + public float TargetCompressionRatio = 0.3f; + + /// + /// Enable smart preservation of asset-related events. + /// + public bool PreserveAssetEvents = true; + + /// + /// Enable smart preservation of error/failure events. + /// + public bool PreserveErrorEvents = true; + } + + /// + /// Result of context compression. + /// + public sealed class CompressedContext + { + public List PreservedEvents; + public List DehydratedEvents; + public List SummaryEvents; + + // Statistics + public int OriginalCount; + public int PreservedCount; + public int DehydratedCount; + public int SummaryCount; + public float CompressionRatio; + + public int TotalEvents => PreservedCount + DehydratedCount + SummaryCount; + } + + /// + /// Compresses event context to reduce memory while preserving important information. + /// + /// Strategy: + /// 1. Always keep critical events (high importance, errors, builds) + /// 2. Keep recent events with full payload + /// 3. Dehydrate older events (payload = null) + /// 4. Generate summary for long-running operations + /// + public sealed class ContextCompressor + { + private readonly ContextCompressionConfig _config; + private readonly IEventScorer _scorer; + + public ContextCompressor(ContextCompressionConfig config = null, IEventScorer scorer = null) + { + _config = config ?? new ContextCompressionConfig(); + _scorer = scorer ?? new Semantics.DefaultEventScorer(); + } + + /// + /// Compress a list of events, preserving important information. + /// Returns a new list with compressed events (original list is not modified). + /// + public List Compress(IReadOnlyList events) + { + if (events == null || events.Count == 0) + return new List(); + + var result = new CompressedContext + { + OriginalCount = events.Count, + PreservedEvents = new List(), + DehydratedEvents = new List(), + SummaryEvents = new List() + }; + + long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + long recentThresholdMs = nowMs - (_config.RecentEventsWindowMinutes * 60 * 1000); + + // Separate events into categories + foreach (var evt in events) + { + if (ShouldPreserveFull(evt, nowMs, recentThresholdMs)) + { + result.PreservedEvents.Add(evt); + } + else + { + // Create dehydrated copy + var dehydrated = evt.Dehydrate(); + result.DehydratedEvents.Add(dehydrated); + } + } + + // Sort preserved events by timestamp + result.PreservedEvents.Sort((a, b) => a.TimestampUnixMs.CompareTo(b.TimestampUnixMs)); + + // Limit total count if needed + int totalAfterPreserve = result.PreservedEvents.Count + result.DehydratedEvents.Count; + if (totalAfterPreserve > _config.MaxCompressedEvents) + { + // Keep all preserved, trim dehydrated + int maxDehydrated = _config.MaxCompressedEvents - result.PreservedEvents.Count; + if (maxDehydrated < 0) maxDehydrated = 0; + + // Keep most recent dehydrated events + result.DehydratedEvents = result.DehydratedEvents + .OrderByDescending(e => e.TimestampUnixMs) + .Take(maxDehydrated) + .ToList(); + } + + // Update statistics + result.PreservedCount = result.PreservedEvents.Count; + result.DehydratedCount = result.DehydratedEvents.Count; + result.CompressionRatio = result.OriginalCount > 0 + ? (float)result.TotalEvents / result.OriginalCount + : 1f; + + // Combine results + var compressed = new List(result.TotalEvents); + compressed.AddRange(result.PreservedEvents); + compressed.AddRange(result.DehydratedEvents); + + // Sort by timestamp + compressed.Sort((a, b) => a.TimestampUnixMs.CompareTo(b.TimestampUnixMs)); + + return compressed; + } + + /// + /// Compress with detailed result information. + /// + public CompressedContext CompressWithDetails(IReadOnlyList events) + { + if (events == null || events.Count == 0) + return new CompressedContext { OriginalCount = 0 }; + + var result = new CompressedContext + { + OriginalCount = events.Count, + PreservedEvents = new List(), + DehydratedEvents = new List(), + SummaryEvents = new List() + }; + + long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + long recentThresholdMs = nowMs - (_config.RecentEventsWindowMinutes * 60 * 1000); + + foreach (var evt in events) + { + if (ShouldPreserveFull(evt, nowMs, recentThresholdMs)) + { + result.PreservedEvents.Add(evt); + } + else + { + var dehydrated = evt.Dehydrate(); + result.DehydratedEvents.Add(dehydrated); + } + } + + result.PreservedEvents.Sort((a, b) => a.TimestampUnixMs.CompareTo(b.TimestampUnixMs)); + + // Apply size limits + ApplySizeLimits(result); + + result.PreservedCount = result.PreservedEvents.Count; + result.DehydratedCount = result.DehydratedEvents.Count; + result.CompressionRatio = result.OriginalCount > 0 + ? (float)result.TotalEvents / result.OriginalCount + : 1f; + + return result; + } + + /// + /// Generate a summary of events for a time range. + /// Useful for compressing long event sequences. + /// + public string SummarizeEvents(IReadOnlyList events, int startIdx, int count) + { + if (events == null || startIdx < 0 || startIdx >= events.Count) + return string.Empty; + + int endIdx = Math.Min(startIdx + count, events.Count); + int actualCount = endIdx - startIdx; + + if (actualCount == 0) + return string.Empty; + + // Count by type + var typeCounts = new Dictionary(); + for (int i = startIdx; i < endIdx; i++) + { + string type = events[i].Type ?? "Unknown"; + typeCounts.TryGetValue(type, out int existingCount); + typeCounts[type] = existingCount + 1; + } + + // Build summary + var summary = new System.Text.StringBuilder(); + summary.Append(actualCount).Append(" events: "); + + int shown = 0; + foreach (var kvp in typeCounts.OrderByDescending(x => x.Value)) + { + if (shown > 0) summary.Append(", "); + summary.Append(kvp.Key).Append(" (").Append(kvp.Value).Append(")"); + shown++; + if (shown >= 5) break; + } + + if (typeCounts.Count > 5) + { + summary.Append(", ..."); + } + + return summary.ToString(); + } + + /// + /// Check if an event should be preserved with full payload. + /// + private bool ShouldPreserveFull(EditorEvent evt, long nowMs, long recentThresholdMs) + { + // Always preserve if in "always keep" list + if (_config.AlwaysKeepEventTypes != null) + { + foreach (string type in _config.AlwaysKeepEventTypes) + { + if (evt.Type == type) + return true; + } + } + + // Always preserve critical events + float importance = _scorer.Score(evt); + if (importance >= _config.CriticalEventThreshold) + return true; + + // Preserve recent events + if (evt.TimestampUnixMs >= recentThresholdMs) + return true; + + // Preserve high importance events + if (importance >= _config.MinImportanceForFullPayload) + return true; + + // Preserve asset events if configured + if (_config.PreserveAssetEvents && IsAssetEvent(evt)) + return true; + + // Preserve error events if configured + if (_config.PreserveErrorEvents && IsErrorEvent(evt)) + return true; + + return false; + } + + private bool IsAssetEvent(EditorEvent evt) + { + return evt.Type == EventTypes.AssetImported || + evt.Type == EventTypes.AssetCreated || + evt.Type == EventTypes.AssetDeleted || + evt.Type == EventTypes.AssetMoved || + evt.Type == EventTypes.AssetModified; + } + + private bool IsErrorEvent(EditorEvent evt) + { + return evt.Type == EventTypes.BuildFailed || + evt.Type == EventTypes.ScriptCompilationFailed || + (evt.Payload != null && evt.Payload.ContainsKey("error")); + } + + private void ApplySizeLimits(CompressedContext result) + { + int totalAfterPreserve = result.PreservedEvents.Count + result.DehydratedEvents.Count; + + if (totalAfterPreserve <= _config.MaxCompressedEvents) + return; + + int maxDehydrated = _config.MaxCompressedEvents - result.PreservedEvents.Count; + if (maxDehydrated < 0) maxDehydrated = 0; + + // Keep most recent dehydrated events + result.DehydratedEvents = result.DehydratedEvents + .OrderByDescending(e => e.TimestampUnixMs) + .Take(maxDehydrated) + .ToList(); + } + } + + /// + /// Extension methods for context compression. + /// + public static class ContextCompressionExtensions + { + /// + /// Compress events with default configuration. + /// + public static List Compress(this IReadOnlyList events) + { + var compressor = new ContextCompressor(); + return compressor.Compress(events); + } + + /// + /// Compress events with custom configuration. + /// + public static List Compress(this IReadOnlyList events, ContextCompressionConfig config) + { + var compressor = new ContextCompressor(config); + return compressor.Compress(events); + } + + /// + /// Compress events targeting a specific count. + /// + public static List CompressTo(this IReadOnlyList events, int targetCount) + { + var config = new ContextCompressionConfig { MaxCompressedEvents = targetCount }; + var compressor = new ContextCompressor(config); + return compressor.Compress(events); + } + + /// + /// Get recent events within a time window. + /// + public static List GetRecent(this IReadOnlyList events, int minutes) + { + if (events == null || events.Count == 0) + return new List(); + + long thresholdMs = DateTimeOffset.UtcNow.AddMinutes(-minutes).ToUnixTimeMilliseconds(); + + return events + .Where(e => e.TimestampUnixMs >= thresholdMs) + .OrderBy(e => e.TimestampUnixMs) + .ToList(); + } + + /// + /// Get high importance events. + /// + public static List GetHighImportance(this IReadOnlyList events, float threshold = 0.7f) + { + if (events == null || events.Count == 0) + return new List(); + + var scorer = new Semantics.DefaultEventScorer(); + + return events + .Where(e => scorer.Score(e) >= threshold) + .OrderByDescending(e => e.TimestampUnixMs) + .ToList(); + } + + /// + /// Get events of specific types. + /// + public static List GetByTypes(this IReadOnlyList events, params string[] types) + { + if (events == null || events.Count == 0 || types == null || types.Length == 0) + return new List(); + + var typeSet = new HashSet(types); + return events.Where(e => typeSet.Contains(e.Type)).ToList(); + } + + /// + /// Deduplicate events by target and type within a time window. + /// + public static List Deduplicate(this IReadOnlyList events, int windowMs = 100) + { + if (events == null || events.Count == 0) + return new List(); + + var seen = new HashSet(); + var result = new List(); + + // Process in chronological order + foreach (var evt in events.OrderBy(e => e.TimestampUnixMs)) + { + string key = $"{evt.Type}|{evt.TargetId}|{evt.TimestampUnixMs / windowMs}"; + + if (seen.Add(key)) + { + result.Add(evt); + } + } + + return result; + } + } +} diff --git a/MCPForUnity/Editor/ActionTrace/Query/EventQueryBuilder.cs b/MCPForUnity/Editor/ActionTrace/Query/EventQueryBuilder.cs new file mode 100644 index 000000000..c90f90655 --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Query/EventQueryBuilder.cs @@ -0,0 +1,698 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MCPForUnity.Editor.ActionTrace.Core; +using MCPForUnity.Editor.ActionTrace.Semantics; + +namespace MCPForUnity.Editor.ActionTrace.Query +{ + /// + /// Sort order for query results. + /// + public enum QuerySortOrder + { + NewestFirst, // Descending by timestamp + OldestFirst, // Ascending by timestamp + HighestImportance, + LowestImportance, + MostRecentTarget + } + + /// + /// Time range filter for queries. + /// + public readonly struct QueryTimeRange + { + public readonly long StartMs; + public readonly long EndMs; + + public QueryTimeRange(long startMs, long endMs) + { + StartMs = startMs; + EndMs = endMs; + } + + /// + /// Last N minutes from now. + /// + public static QueryTimeRange LastMinutes(int minutes) + { + long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + return new QueryTimeRange(now - minutes * 60000, now); + } + + /// + /// Last N hours from now. + /// + public static QueryTimeRange LastHours(int hours) + { + long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + return new QueryTimeRange(now - hours * 3600000, now); + } + + /// + /// Between two Unix timestamps. + /// + public static QueryTimeRange Between(long startMs, long endMs) + { + return new QueryTimeRange(startMs, endMs); + } + + /// + /// Since a specific Unix timestamp. + /// + public static QueryTimeRange Since(long timestampMs) + { + return new QueryTimeRange(timestampMs, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + } + } + + /// + /// Fluent builder for querying ActionTrace events. + /// + /// Provides a chainable API for common query patterns: + /// + /// var results = EventQuery.Query() + /// .OfType(EventTypes.PropertyModified) + /// .WithImportance(ImportanceLevel.High) + /// .InLastMinutes(10) + /// .OrderBy(QuerySortOrder.NewestFirst) + /// .Limit(50) + /// .Execute(); + /// + public sealed class EventQueryBuilder + { + private readonly IEventScorer _scorer; + private readonly IEventCategorizer _categorizer; + + // Filter state + private HashSet _includedTypes; + private HashSet _excludedTypes; + private HashSet _includedCategories; + private HashSet _excludedCategories; + private HashSet _includedTargets; + private HashSet _searchTerms; + private float? _minImportance; + private float? _maxImportance; + private QueryTimeRange? _timeRange; + private QuerySortOrder _sortOrder = QuerySortOrder.NewestFirst; + private int? _limit; + private int? _offset; + + public EventQueryBuilder(IEventScorer scorer = null, IEventCategorizer categorizer = null) + { + _scorer = scorer ?? new DefaultEventScorer(); + _categorizer = categorizer ?? new DefaultCategorizer(); + } + + // ========== Type Filters ========== + + /// + /// Filter to events of the specified type. + /// + public EventQueryBuilder OfType(string eventType) + { + _includedTypes ??= new HashSet(); + _includedTypes.Add(eventType); + return this; + } + + /// + /// Filter to events of any of the specified types. + /// + public EventQueryBuilder OfTypes(params string[] eventTypes) + { + _includedTypes ??= new HashSet(); + foreach (string type in eventTypes) + _includedTypes.Add(type); + return this; + } + + /// + /// Exclude events of the specified type. + /// + public EventQueryBuilder NotOfType(string eventType) + { + _excludedTypes ??= new HashSet(); + _excludedTypes.Add(eventType); + return this; + } + + /// + /// Exclude events of any of the specified types. + /// + public EventQueryBuilder NotOfTypes(params string[] eventTypes) + { + _excludedTypes ??= new HashSet(); + foreach (string type in eventTypes) + _excludedTypes.Add(type); + return this; + } + + // ========== Category Filters ========== + + /// + /// Filter to events in the specified category. + /// + public EventQueryBuilder InCategory(EventCategory category) + { + _includedCategories ??= new HashSet(); + _includedCategories.Add(category); + return this; + } + + /// + /// Filter to events in any of the specified categories. + /// + public EventQueryBuilder InCategories(params EventCategory[] categories) + { + _includedCategories ??= new HashSet(); + foreach (var cat in categories) + _includedCategories.Add(cat); + return this; + } + + /// + /// Exclude events in the specified category. + /// + public EventQueryBuilder NotInCategory(EventCategory category) + { + _excludedCategories ??= new HashSet(); + _excludedCategories.Add(category); + return this; + } + + // ========== Target Filters ========== + + /// + /// Filter to events for the specified target ID. + /// + public EventQueryBuilder ForTarget(string targetId) + { + _includedTargets ??= new HashSet(); + _includedTargets.Add(targetId); + return this; + } + + /// + /// Filter to events for any of the specified targets. + /// + public EventQueryBuilder ForTargets(params string[] targetIds) + { + _includedTargets ??= new HashSet(); + foreach (string id in targetIds) + _includedTargets.Add(id); + return this; + } + + // ========== Importance Filters ========== + + /// + /// Filter to events with minimum importance score. + /// + public EventQueryBuilder WithMinImportance(float minScore) + { + _minImportance = minScore; + return this; + } + + /// + /// Filter to events with maximum importance score. + /// + public EventQueryBuilder WithMaxImportance(float maxScore) + { + _maxImportance = maxScore; + return this; + } + + /// + /// Filter to events within an importance range. + /// + public EventQueryBuilder WithImportanceBetween(float minScore, float maxScore) + { + _minImportance = minScore; + _maxImportance = maxScore; + return this; + } + + /// + /// Filter to critical events only. + /// + public EventQueryBuilder CriticalOnly() + { + return WithMinImportance(0.9f); + } + + /// + /// Filter to important events (high and critical). + /// + public EventQueryBuilder ImportantOnly() + { + return WithMinImportance(0.7f); + } + + // ========== Time Filters ========== + + /// + /// Filter to events within the specified time range. + /// + public EventQueryBuilder InTimeRange(QueryTimeRange range) + { + _timeRange = range; + return this; + } + + /// + /// Filter to events in the last N minutes. + /// + public EventQueryBuilder InLastMinutes(int minutes) + { + _timeRange = QueryTimeRange.LastMinutes(minutes); + return this; + } + + /// + /// Filter to events in the last N hours. + /// + public EventQueryBuilder InLastHours(int hours) + { + _timeRange = QueryTimeRange.LastHours(hours); + return this; + } + + /// + /// Filter to events since a specific timestamp. + /// + public EventQueryBuilder Since(long timestampMs) + { + _timeRange = QueryTimeRange.Since(timestampMs); + return this; + } + + /// + /// Filter to events between two timestamps. + /// + public EventQueryBuilder Between(long startMs, long endMs) + { + _timeRange = QueryTimeRange.Between(startMs, endMs); + return this; + } + + // ========== Search Filters ========== + + /// + /// Filter to events containing any of the search terms (case-insensitive). + /// Searches in summary text and target ID. + /// + public EventQueryBuilder WithSearchTerm(string term) + { + _searchTerms ??= new HashSet(StringComparer.OrdinalIgnoreCase); + _searchTerms.Add(term); + return this; + } + + /// + /// Filter to events containing all of the search terms. + /// + public EventQueryBuilder WithAllSearchTerms(params string[] terms) + { + _searchTerms ??= new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (string term in terms) + _searchTerms.Add(term); + return this; + } + + // ========== Sort & Pagination ========== + + /// + /// Set the sort order for results. + /// + public EventQueryBuilder OrderBy(QuerySortOrder order) + { + _sortOrder = order; + return this; + } + + /// + /// Limit the number of results. + /// + public EventQueryBuilder Limit(int count) + { + _limit = count; + return this; + } + + /// + /// Skip the first N results (for pagination). + /// + public EventQueryBuilder Skip(int count) + { + _offset = count; + return this; + } + + /// + /// Set pagination with page number and page size. + /// + public EventQueryBuilder Page(int pageNumber, int pageSize) + { + _offset = pageNumber * pageSize; + _limit = pageSize; + return this; + } + + // ========== Execution ========== + + /// + /// Execute the query and return matching events. + /// + public List Execute() + { + // Get all events from store + var allEvents = EventStore.Query(int.MaxValue); + + // Apply filters + var filtered = allEvents.Where(MatchesFilters); + + // Sort + filtered = ApplySorting(filtered); + + // Pagination + if (_offset.HasValue) + filtered = filtered.Skip(_offset.Value); + if (_limit.HasValue) + filtered = filtered.Take(_limit.Value); + + return filtered.ToList(); + } + + /// + /// Execute the query and return projected view items. + /// + public List ExecuteProjected() + { + var events = Execute(); + var query = new ActionTraceQuery(_scorer, _categorizer, null); + return query.Project(events).ToList(); + } + + /// + /// Execute the query and return the first matching event, or null. + /// + public EditorEvent FirstOrDefault() + { + return Execute().FirstOrDefault(); + } + + /// + /// Execute the query and return the last matching event, or null. + /// + public EditorEvent LastOrDefault() + { + return Execute().LastOrDefault(); + } + + /// + /// Count events matching the query (without fetching full results). + /// + public int Count() + { + var allEvents = EventStore.Query(int.MaxValue); + return allEvents.Count(MatchesFilters); + } + + /// + /// Check if any events match the query. + /// + public bool Any() + { + var allEvents = EventStore.Query(int.MaxValue); + return allEvents.Any(MatchesFilters); + } + + // ========== Internal ========== + + private bool MatchesFilters(EditorEvent evt) + { + // Type filters + if (_includedTypes != null && !_includedTypes.Contains(evt.Type)) + return false; + + if (_excludedTypes != null && _excludedTypes.Contains(evt.Type)) + return false; + + // Category filters + if (_includedCategories != null || _excludedCategories != null) + { + var meta = EventTypes.Metadata.Get(evt.Type); + EventCategory category = meta.Category; + + if (_includedCategories != null && !_includedCategories.Contains(category)) + return false; + + if (_excludedCategories != null && _excludedCategories.Contains(category)) + return false; + } + + // Target filters + if (_includedTargets != null && !_includedTargets.Contains(evt.TargetId)) + return false; + + // Importance filters + float score = _scorer.Score(evt); + if (_minImportance.HasValue && score < _minImportance.Value) + return false; + + if (_maxImportance.HasValue && score > _maxImportance.Value) + return false; + + // Time range filters + if (_timeRange.HasValue) + { + var range = _timeRange.Value; + if (evt.TimestampUnixMs < range.StartMs || evt.TimestampUnixMs > range.EndMs) + return false; + } + + // Search filters + if (_searchTerms != null && _searchTerms.Count > 0) + { + string summary = (evt.GetSummary() ?? "").ToLowerInvariant(); + string target = (evt.TargetId ?? "").ToLowerInvariant(); + + bool matchesAny = false; + foreach (string term in _searchTerms) + { + string lowerTerm = term.ToLowerInvariant(); + if (summary.Contains(lowerTerm) || target.Contains(lowerTerm)) + { + matchesAny = true; + break; + } + } + + if (!matchesAny) + return false; + } + + return true; + } + + private IEnumerable ApplySorting(IEnumerable source) + { + return _sortOrder switch + { + QuerySortOrder.NewestFirst => source.OrderByDescending(e => e.TimestampUnixMs), + QuerySortOrder.OldestFirst => source.OrderBy(e => e.TimestampUnixMs), + QuerySortOrder.HighestImportance => source.OrderByDescending(e => _scorer.Score(e)), + QuerySortOrder.LowestImportance => source.OrderBy(e => _scorer.Score(e)), + QuerySortOrder.MostRecentTarget => source.GroupBy(e => e.TargetId) + .Select(g => g.OrderByDescending(e => e.TimestampUnixMs).First()) + .OrderByDescending(e => e.TimestampUnixMs), + _ => source.OrderByDescending(e => e.TimestampUnixMs) + }; + } + } + + /// + /// Static entry point for creating queries. + /// + public static class EventQuery + { + /// + /// Create a new query builder. + /// + public static EventQueryBuilder Query() + { + return new EventQueryBuilder(); + } + + /// + /// Create a query with custom semantic components. + /// + public static EventQueryBuilder Query(IEventScorer scorer, IEventCategorizer categorizer = null) + { + return new EventQueryBuilder(scorer, categorizer); + } + + /// + /// Get all events (unfiltered). + /// + public static List All() + { + return EventStore.Query(int.MaxValue).ToList(); + } + + /// + /// Get recent events from the last N minutes. + /// + public static List Recent(int minutes = 10) + { + return Query() + .InLastMinutes(minutes) + .Execute(); + } + + /// + /// Get events for a specific target. + /// + public static List ForTarget(string targetId) + { + return Query() + .ForTarget(targetId) + .Execute(); + } + + /// + /// Get critical events. + /// + public static List Critical() + { + return Query() + .CriticalOnly() + .Execute(); + } + + /// + /// Get events of a specific type. + /// + public static List ByType(string eventType) + { + return Query() + .OfType(eventType) + .Execute(); + } + + /// + /// Search events by text. + /// + public static List Search(string searchTerm) + { + return Query() + .WithSearchTerm(searchTerm) + .Execute(); + } + + /// + /// Get the most recent N events. + /// + public static List Latest(int count = 50) + { + return Query() + .OrderBy(QuerySortOrder.NewestFirst) + .Limit(count) + .Execute(); + } + } + + /// + /// Extension methods for IEnumerable to enable post-query filtering. + /// + public static class EventQueryExtensions + { + /// + /// Filter to events of specific types. + /// + public static IEnumerable OfTypes(this IEnumerable source, params string[] types) + { + var typeSet = new HashSet(types); + return source.Where(e => typeSet.Contains(e.Type)); + } + + /// + /// Filter to events within a time range. + /// + public static IEnumerable InRange(this IEnumerable source, long startMs, long endMs) + { + return source.Where(e => e.TimestampUnixMs >= startMs && e.TimestampUnixMs <= endMs); + } + + /// + /// Filter to recent events within N minutes. + /// + public static IEnumerable Recent(this IEnumerable source, int minutes) + { + long threshold = DateTimeOffset.UtcNow.AddMinutes(-minutes).ToUnixTimeMilliseconds(); + return source.Where(e => e.TimestampUnixMs >= threshold); + } + + /// + /// Filter to events for a specific target. + /// + public static IEnumerable ForTarget(this IEnumerable source, string targetId) + { + return source.Where(e => e.TargetId == targetId); + } + + /// + /// Sort events by timestamp (newest first). + /// + public static IEnumerable NewestFirst(this IEnumerable source) + { + return source.OrderByDescending(e => e.TimestampUnixMs); + } + + /// + /// Sort events by timestamp (oldest first). + /// + public static IEnumerable OldestFirst(this IEnumerable source) + { + return source.OrderBy(e => e.TimestampUnixMs); + } + + /// + /// Get unique target IDs from events. + /// + public static IEnumerable UniqueTargets(this IEnumerable source) + { + return source.Select(e => e.TargetId) + .Where(id => !string.IsNullOrEmpty(id)) + .Distinct(); + } + + /// + /// Group events by target ID. + /// + public static IEnumerable> GroupByTarget(this IEnumerable source) + { + return source.Where(e => !string.IsNullOrEmpty(e.TargetId)) + .GroupBy(e => e.TargetId); + } + + /// + /// Group events by type. + /// + public static IEnumerable> GroupByType(this IEnumerable source) + { + return source.GroupBy(e => e.Type ?? "Unknown"); + } + + /// + /// Convert to list (convenience method). + /// + public static List ToList(this IEnumerable source) + { + return new List(source); + } + } +} diff --git a/MCPForUnity/Editor/ActionTrace/Query/EventStatistics.cs b/MCPForUnity/Editor/ActionTrace/Query/EventStatistics.cs new file mode 100644 index 000000000..3f609ce7d --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Query/EventStatistics.cs @@ -0,0 +1,633 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MCPForUnity.Editor.ActionTrace.Core; +using MCPForUnity.Editor.ActionTrace.Semantics; + +namespace MCPForUnity.Editor.ActionTrace.Query +{ + /// + /// Statistical analysis results for event data. + /// Provides AI-friendly insights about captured events. + /// + public sealed class EventStatistics + { + // Basic counts + public int TotalEvents; + public int CriticalEvents; + public int HighImportanceEvents; + public int MediumImportanceEvents; + public int LowImportanceEvents; + + // Time range + public long TimeRangeStartMs; + public long TimeRangeEndMs; + public long TimeSpanMs; + public double EventsPerMinute; + + // Type distribution + public Dictionary EventTypeCounts; + public Dictionary CategoryCounts; + + // Activity patterns + public List ActivePeriods; + public List IdlePeriods; + + // Top targets + public List TopTargets; + + // Errors and issues + public List ErrorMessages; + public int ErrorCount; + + // Recent trends (last N minutes) + public TrendInfo RecentTrend; + + // Memory usage + public long EstimatedMemoryBytes; + public string EstimatedMemoryFormatted; + + public override string ToString() + { + string trendStr = RecentTrend?.Direction.ToString() ?? "Unknown"; + return $"Events: {TotalEvents} | " + + $"Critical: {CriticalEvents} | " + + $"Time: {FormatTimeRange()} | " + + $"Trend: {trendStr}"; + } + + private string FormatTimeRange() + { + if (TimeSpanMs < 60000) + return $"{TimeSpanMs / 1000}s"; + if (TimeSpanMs < 3600000) + return $"{TimeSpanMs / 60000}m"; + return $"{TimeSpanMs / 3600000}h"; + } + } + + /// + /// Represents a period of heightened or reduced activity. + /// + public sealed class ActivityPeriod + { + public long StartMs; + public long EndMs; + public int EventCount; + public double EventsPerMinute; + public bool IsHighActivity; + + public TimeSpan Duration => TimeSpan.FromMilliseconds(EndMs - StartMs); + + public override string ToString() + { + string activity = IsHighActivity ? "High" : "Low"; + return $"{activity} activity: {EventCount} events over {Duration.TotalMinutes:F1}m ({EventsPerMinute:F1}/min)"; + } + } + + /// + /// Statistics for a specific target (GameObject, asset, etc.). + /// + public sealed class TargetStats + { + public string TargetId; + public string DisplayName; + public int EventCount; + public List EventTypes; + public long LastActivityMs; + public double ActivityScore; // Composite score of frequency + recency + + public override string ToString() + { + return $"{DisplayName}: {EventCount} events (score: {ActivityScore:F1})"; + } + } + + /// + /// Trend information for recent events. + /// + public sealed class TrendInfo + { + public TrendDirection Direction; + public double ChangePercentage; + public int PreviousCount; + public int CurrentCount; + + public override string ToString() + { + return $"{Direction} ({ChangePercentage:+0;-0}%): {PreviousCount} → {CurrentCount} events"; + } + } + + /// + /// Direction of activity trend. + /// + public enum TrendDirection + { + Increasing, + Decreasing, + Stable + } + + /// + /// Provides statistical analysis of event data. + /// Designed to give AI agents basic data insights. + /// + public sealed class EventStatisticsAnalyzer + { + private readonly IEventScorer _scorer; + private readonly IEventCategorizer _categorizer; + + public EventStatisticsAnalyzer(IEventScorer scorer = null, IEventCategorizer categorizer = null) + { + _scorer = scorer ?? new DefaultEventScorer(); + _categorizer = categorizer ?? new DefaultCategorizer(); + } + + /// + /// Analyze a collection of events and return statistics. + /// + public EventStatistics Analyze(IReadOnlyList events) + { + if (events == null || events.Count == 0) + { + return new EventStatistics(); + } + + var stats = new EventStatistics + { + TotalEvents = events.Count + }; + + // Time range + var orderedEvents = events.OrderBy(e => e.TimestampUnixMs).ToList(); + stats.TimeRangeStartMs = orderedEvents[0].TimestampUnixMs; + stats.TimeRangeEndMs = orderedEvents[^1].TimestampUnixMs; + stats.TimeSpanMs = stats.TimeRangeEndMs - stats.TimeRangeStartMs; + + double minutes = stats.TimeSpanMs / 60000.0; + stats.EventsPerMinute = minutes > 0 ? stats.TotalEvents / minutes : 0; + + // Importance distribution + foreach (var evt in events) + { + float score = _scorer.Score(evt); + string category = _categorizer.Categorize(score); + + switch (category) + { + case "critical": + stats.CriticalEvents++; + break; + case "high": + stats.HighImportanceEvents++; + break; + case "medium": + stats.MediumImportanceEvents++; + break; + case "low": + stats.LowImportanceEvents++; + break; + } + } + + // Type distribution + stats.EventTypeCounts = new Dictionary(); + foreach (var evt in events) + { + string type = evt.Type ?? "Unknown"; + stats.EventTypeCounts.TryGetValue(type, out int count); + stats.EventTypeCounts[type] = count + 1; + } + + // Category distribution + stats.CategoryCounts = new Dictionary(); + foreach (var evt in events) + { + var meta = EventTypes.Metadata.Get(evt.Type); + EventCategory category = meta?.Category ?? EventCategory.Unknown; + stats.CategoryCounts.TryGetValue(category, out int count); + stats.CategoryCounts[category] = count + 1; + } + + // Activity periods + stats.ActivePeriods = FindActivePeriods(orderedEvents); + stats.IdlePeriods = FindIdlePeriods(orderedEvents); + + // Top targets + stats.TopTargets = FindTopTargets(events); + + // Errors + stats.ErrorMessages = FindErrors(events); + stats.ErrorCount = stats.ErrorMessages.Count; + + // Recent trend + stats.RecentTrend = CalculateTrend(orderedEvents); + + // Memory estimate + stats.EstimatedMemoryBytes = EstimateMemory(events); + stats.EstimatedMemoryFormatted = FormatMemory(stats.EstimatedMemoryBytes); + + return stats; + } + + /// + /// Get a quick summary suitable for AI consumption. + /// + public string GetSummary(EventStatistics stats) + { + if (stats == null) return "No statistics available"; + + var summary = new System.Text.StringBuilder(); + + summary.AppendLine("=== Event Statistics ==="); + summary.AppendLine($"Total Events: {stats.TotalEvents}"); + summary.AppendLine($"Time Range: {FormatTimestamp(stats.TimeRangeStartMs)} - {FormatTimestamp(stats.TimeRangeEndMs)}"); + summary.AppendLine($"Duration: {TimeSpan.FromMilliseconds(stats.TimeSpanMs).TotalMinutes:F1} minutes"); + summary.AppendLine($"Event Rate: {stats.EventsPerMinute:F1} events/minute"); + summary.AppendLine(); + + summary.AppendLine("Importance Distribution:"); + summary.AppendLine($" Critical: {stats.CriticalEvents}"); + summary.AppendLine($" High: {stats.HighImportanceEvents}"); + summary.AppendLine($" Medium: {stats.MediumImportanceEvents}"); + summary.AppendLine($" Low: {stats.LowImportanceEvents}"); + summary.AppendLine(); + + if (stats.TopTargets.Count > 0) + { + summary.AppendLine("Top Targets:"); + foreach (var target in stats.TopTargets.Take(5)) + { + summary.AppendLine($" - {target.DisplayName}: {target.EventCount} events"); + } + summary.AppendLine(); + } + + if (stats.ErrorCount > 0) + { + summary.AppendLine($"Errors: {stats.ErrorCount}"); + foreach (var error in stats.ErrorMessages.Take(3)) + { + summary.AppendLine($" - {error}"); + } + summary.AppendLine(); + } + + if (stats.RecentTrend != null) + { + summary.AppendLine($"Trend: {stats.RecentTrend.Direction} ({stats.RecentTrend.ChangePercentage:+0;-0}%)"); + } + + return summary.ToString(); + } + + /// + /// Get event distribution by type as a formatted string. + /// + public string GetEventTypeDistribution(EventStatistics stats) + { + if (stats?.EventTypeCounts == null || stats.EventTypeCounts.Count == 0) + return "No event types recorded"; + + var sorted = stats.EventTypeCounts.OrderByDescending(x => x.Value); + var lines = new List(); + + foreach (var kvp in sorted) + { + double percentage = (kvp.Value * 100.0) / stats.TotalEvents; + lines.Add($" {kvp.Key}: {kvp.Value} ({percentage:F1}%)"); + } + + return string.Join("\n", lines); + } + + /// + /// Get activity insights for the current session. + /// + public string GetActivityInsights(EventStatistics stats) + { + if (stats == null) return "No insights available"; + + var insights = new System.Text.StringBuilder(); + + // Overall activity level + string activityLevel = stats.EventsPerMinute switch + { + > 10 => "Very High", + > 5 => "High", + > 2 => "Moderate", + > 0.5 => "Low", + _ => "Very Low" + }; + insights.AppendLine($"Activity Level: {activityLevel} ({stats.EventsPerMinute:F1} events/min)"); + + // Pattern detection + if (stats.ActivePeriods.Count > 0) + { + var peak = stats.ActivePeriods.OrderByDescending(p => p.EventsPerMinute).FirstOrDefault(); + if (peak != null) + { + insights.AppendLine($"Peak Activity: {peak.EventsPerMinute:F1} events/min"); + } + } + + // Error rate + if (stats.TotalEvents > 0) + { + double errorRate = (stats.ErrorCount * 100.0) / stats.TotalEvents; + insights.AppendLine($"Error Rate: {errorRate:F1}%"); + } + + // Critical events + if (stats.CriticalEvents > 0) + { + insights.AppendLine($"Critical Events: {stats.CriticalEvents} (requires attention)"); + } + + return insights.ToString(); + } + + // Private helper methods + + private List FindActivePeriods(List orderedEvents) + { + if (orderedEvents.Count < 10) + return new List(); + + var periods = new List(); + const int windowSize = 10; + const double activityThreshold = 5.0; // events per minute + + for (int i = 0; i <= orderedEvents.Count - windowSize; i += windowSize) + { + long windowStart = orderedEvents[i].TimestampUnixMs; + long windowEnd = orderedEvents[Math.Min(i + windowSize - 1, orderedEvents.Count - 1)].TimestampUnixMs; + + double eventsPerMin = (windowSize * 60000.0) / Math.Max(1, windowEnd - windowStart); + + if (eventsPerMin >= activityThreshold) + { + periods.Add(new ActivityPeriod + { + StartMs = windowStart, + EndMs = windowEnd, + EventCount = windowSize, + EventsPerMinute = eventsPerMin, + IsHighActivity = true + }); + } + } + + return periods; + } + + private List FindIdlePeriods(List orderedEvents) + { + if (orderedEvents.Count < 2) + return new List(); + + var periods = new List(); + const long idleThresholdMs = 30000; // 30 seconds with no events + + for (int i = 1; i < orderedEvents.Count; i++) + { + long gap = orderedEvents[i].TimestampUnixMs - orderedEvents[i - 1].TimestampUnixMs; + + if (gap >= idleThresholdMs) + { + periods.Add(new ActivityPeriod + { + StartMs = orderedEvents[i - 1].TimestampUnixMs, + EndMs = orderedEvents[i].TimestampUnixMs, + EventCount = 0, + EventsPerMinute = 0, + IsHighActivity = false + }); + } + } + + return periods; + } + + private List FindTopTargets(IReadOnlyList events) + { + var targetEvents = new Dictionary>(); + + foreach (var evt in events) + { + if (string.IsNullOrEmpty(evt.TargetId)) continue; + + if (!targetEvents.TryGetValue(evt.TargetId, out var list)) + { + list = new List(); + targetEvents[evt.TargetId] = list; + } + list.Add(evt); + } + + var nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + var stats = new List(); + + foreach (var kvp in targetEvents) + { + var targetEventsList = kvp.Value; + double recencyFactor = CalculateRecencyFactor(targetEventsList, nowMs); + double activityScore = targetEventsList.Count * recencyFactor; + + stats.Add(new TargetStats + { + TargetId = kvp.Key, + DisplayName = GetDisplayName(targetEventsList[^1]), + EventCount = targetEventsList.Count, + EventTypes = targetEventsList.Select(e => e.Type).Distinct().ToList(), + LastActivityMs = targetEventsList[^1].TimestampUnixMs, + ActivityScore = activityScore + }); + } + + return stats.OrderByDescending(s => s.ActivityScore).Take(10).ToList(); + } + + private double CalculateRecencyFactor(List events, long nowMs) + { + if (events.Count == 0) return 0; + + long lastActivity = events[^1].TimestampUnixMs; + long ageMs = nowMs - lastActivity; + + // Decay factor: 1.0 for recent, 0.1 for old (> 1 hour) + if (ageMs < 300000) return 1.0; // < 5 min + if (ageMs < 900000) return 0.7; // < 15 min + if (ageMs < 1800000) return 0.5; // < 30 min + if (ageMs < 3600000) return 0.3; // < 1 hour + return 0.1; + } + + private string GetDisplayName(EditorEvent evt) + { + if (evt.Payload != null) + { + if (evt.Payload.TryGetValue("name", out var name)) + return name.ToString(); + if (evt.Payload.TryGetValue("game_object", out var go)) + return go.ToString(); + if (evt.Payload.TryGetValue("scene_name", out var scene)) + return scene.ToString(); + } + return evt.TargetId ?? "Unknown"; + } + + private List FindErrors(IReadOnlyList events) + { + var errors = new List(); + + foreach (var evt in events) + { + if (evt.Type == EventTypes.BuildFailed) + { + if (evt.Payload != null && evt.Payload.TryGetValue("error", out var error)) + errors.Add($"Build: {error}"); + else + errors.Add("Build failed"); + } + else if (evt.Type == EventTypes.ScriptCompilationFailed) + { + if (evt.Payload != null && evt.Payload.TryGetValue("errors", out var errs)) + errors.Add($"Compilation: {errs}"); + else + errors.Add("Script compilation failed"); + } + else if (evt.Payload != null && evt.Payload.TryGetValue("error", out var err)) + { + errors.Add(err.ToString()); + } + } + + return errors; + } + + private TrendInfo CalculateTrend(List orderedEvents) + { + if (orderedEvents.Count < 10) + return null; + + long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + long recentWindowMs = 5 * 60 * 1000; // Last 5 minutes + long previousWindowMs = 5 * 60 * 1000; // 5-10 minutes ago + + long recentStart = nowMs - recentWindowMs; + long previousEnd = recentStart; + long previousStart = previousEnd - previousWindowMs; + + int recentCount = orderedEvents.Count(e => e.TimestampUnixMs >= recentStart); + int previousCount = orderedEvents.Count(e => e.TimestampUnixMs >= previousStart && e.TimestampUnixMs < previousEnd); + + TrendDirection direction; + double changePercent = 0; + + if (previousCount == 0) + { + direction = recentCount > 0 ? TrendDirection.Increasing : TrendDirection.Stable; + } + else + { + changePercent = ((recentCount - previousCount) * 100.0) / previousCount; + + if (changePercent > 20) + direction = TrendDirection.Increasing; + else if (changePercent < -20) + direction = TrendDirection.Decreasing; + else + direction = TrendDirection.Stable; + } + + return new TrendInfo + { + Direction = direction, + ChangePercentage = changePercent, + PreviousCount = previousCount, + CurrentCount = recentCount + }; + } + + private long EstimateMemory(IReadOnlyList events) + { + // Approximate: 300 bytes per hydrated event, 100 bytes per dehydrated + long total = 0; + foreach (var evt in events) + { + total += evt.Payload == null ? 100 : 300; + } + return total; + } + + private string FormatMemory(long bytes) + { + if (bytes < 1024) return $"{bytes} B"; + if (bytes < 1024 * 1024) return $"{bytes / 1024} KB"; + return $"{bytes / (1024 * 1024)} MB"; + } + + private string FormatTimestamp(long ms) + { + var dt = DateTimeOffset.FromUnixTimeMilliseconds(ms).ToLocalTime(); + return dt.ToString("HH:mm:ss"); + } + } + + /// + /// Extension methods for quick statistics. + /// + public static class StatisticsExtensions + { + /// + /// Get quick statistics for events. + /// + public static EventStatistics GetStatistics(this IReadOnlyList events) + { + var analyzer = new EventStatisticsAnalyzer(); + return analyzer.Analyze(events); + } + + /// + /// Get a quick summary of events. + /// + public static string GetQuickSummary(this IReadOnlyList events) + { + if (events == null || events.Count == 0) + return "No events"; + + var analyzer = new EventStatisticsAnalyzer(); + var stats = analyzer.Analyze(events); + return analyzer.GetSummary(stats); + } + + /// + /// Count events by type. + /// + public static Dictionary CountByType(this IReadOnlyList events) + { + var counts = new Dictionary(); + if (events == null) return counts; + + foreach (var evt in events) + { + string type = evt.Type ?? "Unknown"; + counts.TryGetValue(type, out int count); + counts[type] = count + 1; + } + return counts; + } + + /// + /// Get most recent N events. + /// + public static List GetMostRecent(this IReadOnlyList events, int count) + { + if (events == null) return new List(); + return events.OrderByDescending(e => e.TimestampUnixMs).Take(count).ToList(); + } + } +} diff --git a/MCPForUnity/Editor/ActionTrace/Query/EventSummarizer.cs b/MCPForUnity/Editor/ActionTrace/Query/EventSummarizer.cs new file mode 100644 index 000000000..21f376957 --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Query/EventSummarizer.cs @@ -0,0 +1,554 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.RegularExpressions; +using MCPForUnity.Editor.ActionTrace.Core; + +namespace MCPForUnity.Editor.ActionTrace.Query +{ + /// + /// Generates human-readable summaries for editor events. + /// + /// Uses event metadata templates for most events, with special handling + /// for complex cases like PropertyModified. + /// + /// Template Syntax: + /// - {key} - Simple placeholder replacement + /// - {if:key, then} - Conditional: insert 'then' if key exists and has meaningful value + /// - {if:key, then, else} - Conditional with else branch + /// - {if_any:key1,key2, then} - Insert 'then' if ANY key has meaningful value + /// - {if_all:key1,key2, then} - Insert 'then' if ALL keys have meaningful value + /// - {eq:key, value, then} - Insert 'then' if key equals value + /// - {ne:key, value, then} - Insert 'then' if key does not equal value + /// - {format:key, format} - Format key value (supports: upper, lower, trim, truncate:N) + /// - {target_id} - GameObject/Target ID for AI tool invocation + /// - {property_path_no_m} - Strip "m_" prefix from Unity properties + /// - {start_value_readable} - Format start value for display + /// - {end_value_readable} - Format end value for display + /// + /// To add summary for a new event: + /// 1. Add SummaryTemplate to the event's metadata in EventTypes.Metadata + /// 2. That's it! No need to add a separate SummarizeXxx method. + /// + public static class EventSummarizer + { + // Precompiled regex patterns for template processing + private static readonly Regex IfPattern = new Regex(@"\{if:([^,}]+),\s*([^}]*)\}", RegexOptions.Compiled); + private static readonly Regex IfElsePattern = new Regex(@"\{if:([^,}]+),\s*([^,}]+),\s*([^}]*)\}", RegexOptions.Compiled); + private static readonly Regex IfAnyPattern = new Regex(@"\{if_any:([^}]+),\s*([^}]*)\}", RegexOptions.Compiled); + private static readonly Regex IfAllPattern = new Regex(@"\{if_all:([^}]+),\s*([^}]*)\}", RegexOptions.Compiled); + private static readonly Regex EqPattern = new Regex(@"\{eq:([^,}]+),\s*([^,}]+),\s*([^}]*)\}", RegexOptions.Compiled); + private static readonly Regex NePattern = new Regex(@"\{ne:([^,}]+),\s*([^,}]+),\s*([^}]*)\}", RegexOptions.Compiled); + private static readonly Regex FormatPattern = new Regex(@"\{format:([^,}]+),\s*([^}]*)\}", RegexOptions.Compiled); + + // Formatting constants + private const int DefaultTruncateLength = 9; + private const int ReadableValueMaxLength = 50; + private const int FormattedValueMaxLength = 100; + private const int TruncatedSuffixLength = 3; + + /// + /// Generate a human-readable summary for an event. + /// Uses metadata templates when available, falls back to special handlers. + /// + public static string Summarize(EditorEvent evt) + { + // Special cases that need complex logic + string specialSummary = GetSpecialCaseSummary(evt); + if (specialSummary != null) + return specialSummary; + + // Use metadata template + var meta = EventTypes.Metadata.Get(evt.Type); + if (!string.IsNullOrEmpty(meta.SummaryTemplate)) + { + return FormatTemplate(meta.SummaryTemplate, evt); + } + + // Default fallback + return $"{evt.Type} on {GetTargetName(evt)}"; + } + + /// + /// Format a template string with event data. + /// + /// Processing order (later patterns can use results of earlier): + /// 1. Conditionals (if, if_any, if_all, eq, ne) + /// 2. Format directives + /// 3. Simple placeholders + /// 4. Special placeholders + /// + private static string FormatTemplate(string template, EditorEvent evt) + { + if (string.IsNullOrEmpty(template)) + return string.Empty; + + string result = template; + + // Process conditionals first (in order of specificity) + result = ProcessIfElse(result, evt); + result = ProcessIfAny(result, evt); + result = ProcessIfAll(result, evt); + result = ProcessSimpleIf(result, evt); + result = ProcessEq(result, evt); + result = ProcessNe(result, evt); + + // Process format directives + result = ProcessFormat(result, evt); + + // Build result with StringBuilder for efficient replacements + var sb = new StringBuilder(result); + + // Handle regular placeholders + foreach (var kvp in evt.Payload ?? new Dictionary()) + { + string placeholder = "{" + kvp.Key + "}"; + int index = 0; + while ((index = sb.ToString().IndexOf(placeholder, index, StringComparison.Ordinal)) >= 0) + { + string value = FormatValue(kvp.Value); + sb.Remove(index, placeholder.Length); + sb.Insert(index, value); + } + } + + // Special placeholders + sb.Replace("{type}", evt.Type ?? ""); + sb.Replace("{target}", GetTargetName(evt) ?? ""); + sb.Replace("{target_id}", evt.TargetId ?? ""); + sb.Replace("{time}", FormatTime(evt.TimestampUnixMs)); + sb.Replace("{property_path_no_m}", StripMPrefix(evt, "property_path")); + sb.Replace("{start_value_readable}", GetReadableValue(evt, "start_value")); + sb.Replace("{end_value_readable}", GetReadableValue(evt, "end_value")); + + return sb.ToString(); + } + + /// + /// Process {if:key, then, else} conditionals with else branch. + /// + private static string ProcessIfElse(string template, EditorEvent evt) + { + return IfElsePattern.Replace(template, match => + { + string key = match.Groups[1].Value.Trim(); + string thenText = match.Groups[2].Value.Trim(); + string elseText = match.Groups[3].Value.Trim(); + return HasMeaningfulValue(evt, key) ? thenText : elseText; + }); + } + + /// + /// Process {if_any:key1,key2, then} - true if ANY key has meaningful value. + /// + private static string ProcessIfAny(string template, EditorEvent evt) + { + return IfAnyPattern.Replace(template, match => + { + string keys = match.Groups[1].Value; + string thenText = match.Groups[2].Value.Trim(); + string[] keyList = keys.Split(','); + + foreach (string key in keyList) + { + if (HasMeaningfulValue(evt, key.Trim())) + return thenText; + } + return ""; + }); + } + + /// + /// Process {if_all:key1,key2, then} - true only if ALL keys have meaningful values. + /// + private static string ProcessIfAll(string template, EditorEvent evt) + { + return IfAllPattern.Replace(template, match => + { + string keys = match.Groups[1].Value; + string thenText = match.Groups[2].Value.Trim(); + string[] keyList = keys.Split(','); + + foreach (string key in keyList) + { + if (!HasMeaningfulValue(evt, key.Trim())) + return ""; + } + return thenText; + }); + } + + /// + /// Process simple {if:key, then} conditionals (without else). + /// Done after if_else to avoid double-processing. + /// + private static string ProcessSimpleIf(string template, EditorEvent evt) + { + return IfPattern.Replace(template, match => + { + // Skip if this looks like part of an already-processed pattern + if (match.Value.Contains(",,")) return match.Value; + + string key = match.Groups[1].Value.Trim(); + string thenText = match.Groups[2].Value.Trim(); + return HasMeaningfulValue(evt, key) ? thenText : ""; + }); + } + + /// + /// Process {eq:key, value, then} - insert 'then' if key equals value. + /// + private static string ProcessEq(string template, EditorEvent evt) + { + return EqPattern.Replace(template, match => + { + string key = match.Groups[1].Value.Trim(); + string expectedValue = match.Groups[2].Value.Trim(); + string thenText = match.Groups[3].Value.Trim(); + + string actualValue = GetPayloadStringValue(evt, key); + return string.Equals(actualValue, expectedValue, StringComparison.Ordinal) ? thenText : ""; + }); + } + + /// + /// Process {ne:key, value, then} - insert 'then' if key does not equal value. + /// + private static string ProcessNe(string template, EditorEvent evt) + { + return NePattern.Replace(template, match => + { + string key = match.Groups[1].Value.Trim(); + string expectedValue = match.Groups[2].Value.Trim(); + string thenText = match.Groups[3].Value.Trim(); + + string actualValue = GetPayloadStringValue(evt, key); + return !string.Equals(actualValue, expectedValue, StringComparison.Ordinal) ? thenText : ""; + }); + } + + /// + /// Process {format:key, format} - format key value. + /// Supported formats: upper, lower, trim, truncate:N, capitalize + /// + private static string ProcessFormat(string template, EditorEvent evt) + { + return FormatPattern.Replace(template, match => + { + string key = match.Groups[1].Value.Trim(); + string format = match.Groups[2].Value.Trim(); + + string value = GetPayloadStringValue(evt, key); + if (string.IsNullOrEmpty(value)) + return ""; + + return format switch + { + "upper" => value.ToUpperInvariant(), + "lower" => value.ToLowerInvariant(), + "trim" => value.Trim(), + "capitalize" => Capitalize(value), + _ when format.StartsWith("truncate:") => Truncate(value, ParseInt(format, DefaultTruncateLength)), + _ => value + }; + }); + } + + /// + /// Gets a string value from payload, or empty string if key doesn't exist. + /// + private static string GetPayloadStringValue(EditorEvent evt, string key) + { + if (evt.Payload != null && evt.Payload.TryGetValue(key, out var value)) + { + return value?.ToString() ?? ""; + } + return ""; + } + + /// + /// Parse integer from format string (e.g., "truncate:20" -> 20). + /// + private static int ParseInt(string format, int defaultValue) + { + int colonIdx = format.IndexOf(':'); + if (colonIdx >= 0 && int.TryParse(format.AsSpan(colonIdx + 1), out int result)) + return result; + return defaultValue; + } + + /// + /// Capitalize first letter of string. + /// + private static string Capitalize(string value) + { + if (string.IsNullOrEmpty(value)) + return value; + return char.ToUpperInvariant(value[0]) + value.Substring(1); + } + + /// + /// Truncate string to max length, adding "..." if truncated. + /// + private static string Truncate(string value, int maxLength) + { + if (string.IsNullOrEmpty(value) || value.Length <= maxLength) + return value; + return value.Substring(0, Math.Max(0, maxLength - TruncatedSuffixLength)) + "..."; + } + + /// + /// Get summary for events that need special handling. + /// Returns null if no special handling is needed. + /// + private static string GetSpecialCaseSummary(EditorEvent evt) + { + return evt.Type switch + { + EventTypes.PropertyModified => SummarizePropertyModified(evt), + EventTypes.SelectionPropertyModified => SummarizeSelectionPropertyModified(evt), + _ => null + }; + } + + /// + /// Generate a human-readable summary for property modification events. + /// Format: "Changed {ComponentType}.{PropertyPath} from {StartValue} to {EndValue} (GameObject:{target_id})" + /// Strips "m_" prefix from Unity serialized property names. + /// Includes GameObject ID for AI tool invocation. + /// + private static string SummarizePropertyModified(EditorEvent evt) + { + if (evt.Payload == null) + return "Property modified"; + + string componentType = GetPayloadString(evt, "component_type"); + string propertyPath = GetPayloadString(evt, "property_path"); + string targetName = GetPayloadString(evt, "target_name"); + + // Strip "m_" prefix from Unity serialized property names + string readableProperty = propertyPath?.StartsWith("m_") == true + ? propertyPath.Substring(2) + : propertyPath; + + string startValue = GetReadableValue(evt, "start_value"); + string endValue = GetReadableValue(evt, "end_value"); + + // Build base summary + string baseSummary; + if (!string.IsNullOrEmpty(componentType) && !string.IsNullOrEmpty(readableProperty)) + { + if (!string.IsNullOrEmpty(startValue) && !string.IsNullOrEmpty(endValue)) + { + baseSummary = $"Changed {componentType}.{readableProperty} from {startValue} to {endValue}"; + } + else + { + baseSummary = $"Changed {componentType}.{readableProperty}"; + } + } + else if (!string.IsNullOrEmpty(targetName)) + { + if (!string.IsNullOrEmpty(readableProperty)) + { + baseSummary = $"Changed {targetName}.{readableProperty}"; + } + else + { + baseSummary = $"Changed {targetName}"; + } + } + else + { + return "Property modified"; + } + + // Append GameObject ID for AI tool invocation + if (!string.IsNullOrEmpty(evt.TargetId)) + { + return $"{baseSummary} (GameObject:{evt.TargetId})"; + } + return baseSummary; + } + + /// + /// Generate a human-readable summary for SelectionPropertyModified events. + /// Same as PropertyModified but adds "(selected)" suffix. + /// Includes GameObject ID for AI tool invocation. + /// + private static string SummarizeSelectionPropertyModified(EditorEvent evt) + { + if (evt.Payload == null) + return "Property modified (selected)"; + + string componentType = GetPayloadString(evt, "component_type"); + string propertyPath = GetPayloadString(evt, "property_path"); + string targetName = GetPayloadString(evt, "target_name"); + + // Strip "m_" prefix from Unity serialized property names + string readableProperty = propertyPath?.StartsWith("m_") == true + ? propertyPath.Substring(2) + : propertyPath; + + string startValue = GetReadableValue(evt, "start_value"); + string endValue = GetReadableValue(evt, "end_value"); + + // Build base summary + string baseSummary; + if (!string.IsNullOrEmpty(componentType) && !string.IsNullOrEmpty(readableProperty)) + { + if (!string.IsNullOrEmpty(startValue) && !string.IsNullOrEmpty(endValue)) + { + baseSummary = $"Changed {componentType}.{readableProperty} from {startValue} to {endValue}"; + } + else + { + baseSummary = $"Changed {componentType}.{readableProperty}"; + } + } + else if (!string.IsNullOrEmpty(targetName)) + { + if (!string.IsNullOrEmpty(readableProperty)) + { + baseSummary = $"Changed {targetName}.{readableProperty}"; + } + else + { + baseSummary = $"Changed {targetName}"; + } + } + else + { + return "Property modified (selected)"; + } + + // Append "selected" and GameObject ID for AI tool invocation + if (!string.IsNullOrEmpty(evt.TargetId)) + { + return $"{baseSummary} (selected, GameObject:{evt.TargetId})"; + } + return $"{baseSummary} (selected)"; + } + + /// + /// Extracts a readable value from the payload, handling JSON formatting. + /// Removes quotes from string values and limits length. + /// + private static string GetReadableValue(EditorEvent evt, string key) + { + if (evt.Payload == null || !evt.Payload.TryGetValue(key, out var value)) + return null; + + string valueStr = value.ToString(); + if (string.IsNullOrEmpty(valueStr)) + return null; + + // Remove quotes from JSON string values + if (valueStr.StartsWith("\"") && valueStr.EndsWith("\"") && valueStr.Length > 1) + { + valueStr = valueStr.Substring(1, valueStr.Length - 2); + } + + // Truncate long values (e.g., long vectors) + if (valueStr.Length > ReadableValueMaxLength) + { + valueStr = valueStr.Substring(0, ReadableValueMaxLength - TruncatedSuffixLength) + "..."; + } + + return valueStr; + } + + /// + /// Gets a string value from payload, or null if key doesn't exist. + /// + private static string GetPayloadString(EditorEvent evt, string key) + { + if (evt.Payload != null && evt.Payload.TryGetValue(key, out var value)) + { + return value?.ToString(); + } + return null; + } + + /// + /// Checks if a payload key has a meaningful (non-empty, non-default) value. + /// + private static bool HasMeaningfulValue(EditorEvent evt, string key) + { + if (evt.Payload == null || !evt.Payload.TryGetValue(key, out var value)) + return false; + + string valueStr = value?.ToString(); + if (string.IsNullOrEmpty(valueStr)) + return false; + + // Check for common "empty" values + if (valueStr == "0" || valueStr == "0.0" || valueStr == "false" || valueStr == "null" || valueStr == "unknown") + return false; + + return true; + } + + /// + /// Format a payload value for display in summaries. + /// + private static string FormatValue(object value) + { + if (value == null) + return ""; + + string str = value.ToString(); + + // Truncate long strings + if (str.Length > FormattedValueMaxLength) + str = str.Substring(0, FormattedValueMaxLength - TruncatedSuffixLength) + "..."; + + return str; + } + + /// + /// Strip "m_" prefix from a payload property value. + /// + private static string StripMPrefix(EditorEvent evt, string key) + { + string value = GetPayloadString(evt, key); + if (value?.StartsWith("m_") == true) + return value.Substring(2); + return value ?? ""; + } + + /// + /// Get a human-readable name for the event target. + /// Tries payload fields in order: name, game_object, scene_name, component_type, path. + /// Falls back to TargetId if none found. + /// + private static string GetTargetName(EditorEvent evt) + { + // Try to get a human-readable name from payload + if (evt.Payload != null) + { + if (evt.Payload.TryGetValue("name", out var name)) + return name.ToString(); + if (evt.Payload.TryGetValue("game_object", out var goName)) + return goName.ToString(); + if (evt.Payload.TryGetValue("scene_name", out var sceneName)) + return sceneName.ToString(); + if (evt.Payload.TryGetValue("component_type", out var compType)) + return compType.ToString(); + if (evt.Payload.TryGetValue("path", out var path)) + return path.ToString(); + } + // Fall back to target ID + return evt.TargetId; + } + + /// + /// Format Unix timestamp to HH:mm:ss time string. + /// + private static string FormatTime(long timestampMs) + { + var dt = DateTimeOffset.FromUnixTimeMilliseconds(timestampMs).ToLocalTime(); + return dt.ToString("HH:mm:ss"); + } + } +} diff --git a/MCPForUnity/Editor/ActionTrace/Query/TransactionAggregator.cs b/MCPForUnity/Editor/ActionTrace/Query/TransactionAggregator.cs new file mode 100644 index 000000000..e46d2ae6f --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Query/TransactionAggregator.cs @@ -0,0 +1,277 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MCPForUnity.Editor.ActionTrace.Core; +using MCPForUnity.Editor.ActionTrace.Helpers; + +namespace MCPForUnity.Editor.ActionTrace.Query +{ + /// + /// Logical transaction aggregator for ActionTrace events. + /// + /// Groups continuous events into "atomic operations" (logical transactions) + /// to reduce token consumption and improve AI efficiency. + /// + /// Aggregation priority (from document ActionTrace-enhancements.md P1.1): + /// 1. ToolCallId boundary (strongest) - Different tool calls split + /// 2. TriggeredByTool boundary - Different tools split + /// 3. Time window boundary (from ActionTraceSettings.TransactionWindowMs) - User operations backup + /// + /// Design principles: + /// - Query-time computation (does not modify stored events) + /// - Preserves EventStore immutability + /// - Compatible with semantic projection layer + /// + /// Usage: + /// var operations = TransactionAggregator.Aggregate(events); + /// // Returns: 50 events → 3 AtomicOperation objects + /// + public static class TransactionAggregator + { + /// + /// Default time window for user operation aggregation (fallback if settings unavailable). + /// Events within 2 seconds are grouped if no ToolId information exists. + /// + private const long DefaultTransactionWindowMs = 2000; + + /// + /// Aggregates a flat list of events into logical transactions. + /// + /// Algorithm (from document decision tree): + /// 1. Check ToolCallId boundary (if exists) + /// 2. Check TriggeredByTool boundary (if exists) + /// 3. Fallback to 2-second time window + /// + /// Returns a list of AtomicOperation objects, each representing + /// a logical group of events (e.g., one tool call). + /// + public static List Aggregate(IReadOnlyList events) + { + if (events == null || events.Count == 0) + return new List(); + + var result = new List(); + var currentBatch = new List(events.Count / 2); // Preallocate half capacity + + for (int i = 0; i < events.Count; i++) + { + var evt = events[i]; + + if (currentBatch.Count == 0) + { + // First event starts a new batch + currentBatch.Add(evt); + continue; + } + + var first = currentBatch[0]; + if (ShouldSplit(first, evt)) + { + // Boundary reached - finalize current batch + if (currentBatch.Count > 0) + result.Add(CreateAtomicOperation(currentBatch)); + + // Start new batch with current event - clear and reuse list + currentBatch.Clear(); + currentBatch.Add(evt); + } + else + { + // Same transaction - add to current batch + currentBatch.Add(evt); + } + } + + // Don't forget the last batch + if (currentBatch.Count > 0) + result.Add(CreateAtomicOperation(currentBatch)); + + return result; + } + + /// + /// Determines if two events should be in different transactions. + /// + /// Decision tree (from ActionTrace-enhancements.md line 274-290): + /// - Priority 1: ToolCallId boundary (mandatory split if different) + /// - Priority 2: TriggeredByTool boundary (mandatory split if different) + /// - Priority 3: Time window (from ActionTraceSettings.TransactionWindowMs, default 2000ms) + /// + private static bool ShouldSplit(EditorEvent first, EditorEvent current) + { + // Get transaction window from settings, with fallback to default + var settings = ActionTraceSettings.Instance; + long transactionWindowMs = settings?.Merging.TransactionWindowMs ?? DefaultTransactionWindowMs; + + // Extract ToolCallId from Payload (if exists) + string firstToolCallId = GetToolCallId(first); + string currentToolCallId = GetToolCallId(current); + + // ========== Priority 1: ToolCallId boundary ========== + // Split if tool call IDs differ (including null vs non-null for symmetry) + if (currentToolCallId != firstToolCallId) + return true; // Different tool call → split + + // ========== Priority 2: TriggeredByTool boundary ========== + string firstTool = GetTriggeredByTool(first); + string currentTool = GetTriggeredByTool(current); + + // Split if tools differ (including null vs non-null for symmetry) + if (currentTool != firstTool) + return true; // Different tool → split + + // ========== Priority 3: Time window (user operations) ========== + // If no ToolId information, use configured time window + long timeDelta = current.TimestampUnixMs - first.TimestampUnixMs; + return timeDelta > transactionWindowMs; + } + + /// + /// Creates an AtomicOperation from a batch of events. + /// + /// Summary generation strategy: + /// - If tool_call_id exists: "ToolName: N events in X.Xs" + /// - If time-based: Use first event's summary + " + N-1 related events" + /// + private static AtomicOperation CreateAtomicOperation(List batch) + { + if (batch == null || batch.Count == 0) + throw new ArgumentException("Batch cannot be empty", nameof(batch)); + + var first = batch[0]; + var last = batch[batch.Count - 1]; + + string toolCallId = GetToolCallId(first); + string toolName = GetTriggeredByTool(first); + + // Generate summary + string summary = GenerateSummary(batch, toolCallId, toolName); + + // Calculate duration + long durationMs = last.TimestampUnixMs - first.TimestampUnixMs; + + return new AtomicOperation + { + StartSequence = first.Sequence, + EndSequence = last.Sequence, + Summary = summary, + EventCount = batch.Count, + DurationMs = durationMs, + ToolCallId = toolCallId, + TriggeredByTool = toolName + }; + } + + /// + /// Generates a human-readable summary for an atomic operation. + /// + private static string GenerateSummary( + List batch, + string toolCallId, + string toolName) + { + if (batch.Count == 1) + { + // Single event - use its summary + return EventSummarizer.Summarize(batch[0]); + } + + // Multiple events + if (!string.IsNullOrEmpty(toolCallId)) + { + // Tool call - use tool name + count + string displayName = string.IsNullOrEmpty(toolName) + ? "AI operation" + : ActionTraceHelper.FormatToolName(toolName); + + return $"{displayName}: {batch.Count} events in {ActionTraceHelper.FormatDurationFromRange(batch[0].TimestampUnixMs, batch[batch.Count - 1].TimestampUnixMs)}"; + } + + // Time-based aggregation - use first event + count + string firstSummary = EventSummarizer.Summarize(batch[0]); + return $"{firstSummary} + {batch.Count - 1} related events"; + } + + /// + /// Extracts a string value from event Payload by key. + /// Returns null if not present or value is null. + /// + private static string GetPayloadString(EditorEvent evt, string key) + { + if (evt.Payload == null) + return null; + + if (evt.Payload.TryGetValue(key, out var value)) + return value?.ToString(); + + return null; + } + + /// + /// Extracts tool_call_id from event Payload. + /// Returns null if not present. + /// + private static string GetToolCallId(EditorEvent evt) => GetPayloadString(evt, "tool_call_id"); + + /// + /// Extracts triggered_by_tool from event Payload. + /// Returns null if not present. + /// + private static string GetTriggeredByTool(EditorEvent evt) => GetPayloadString(evt, "triggered_by_tool"); + } + + /// + /// Represents a logical transaction (atomic operation) composed of multiple events. + /// + /// Use cases: + /// - AI tool call grouping (e.g., "create_complex_object" → 50 events) + /// - User rapid operations (e.g., 5 component additions in 1.5s) + /// - Undo group alignment (one Ctrl+Z = one AtomicOperation) + /// + /// From ActionTrace-enhancements.md P1.1, line 189-198. + /// + public sealed class AtomicOperation + { + /// + /// First event sequence number in this transaction. + /// + public long StartSequence { get; set; } + + /// + /// Last event sequence number in this transaction. + /// + public long EndSequence { get; set; } + + /// + /// Human-readable summary of the entire transaction. + /// Examples: + /// - "Manage GameObject: 50 events in 2.3s" + /// - "Added Rigidbody to Player + 4 related events" + /// + public string Summary { get; set; } + + /// + /// Number of events in this transaction. + /// + public int EventCount { get; set; } + + /// + /// Duration of the transaction in milliseconds. + /// Time from first event to last event. + /// + public long DurationMs { get; set; } + + /// + /// Tool call identifier if this transaction represents a single tool call. + /// Null for time-based user operations. + /// + public string ToolCallId { get; set; } + + /// + /// Tool name that triggered this transaction. + /// Examples: "manage_gameobject", "add_ActionTrace_note" + /// Null for user manual operations. + /// + public string TriggeredByTool { get; set; } + } +} diff --git a/MCPForUnity/Editor/ActionTrace/Semantics/DefaultCategorizer.cs b/MCPForUnity/Editor/ActionTrace/Semantics/DefaultCategorizer.cs new file mode 100644 index 000000000..6331fdfd5 --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Semantics/DefaultCategorizer.cs @@ -0,0 +1,27 @@ +namespace MCPForUnity.Editor.ActionTrace.Semantics +{ + /// + /// Default implementation of event categorization. + /// Maps importance scores to category labels. + /// + public sealed class DefaultCategorizer : IEventCategorizer + { + /// + /// Categorize an importance score into a label. + /// + public string Categorize(float score) + { + // Ensure score is in valid range + if (score < 0f) score = 0f; + if (score > 1f) score = 1f; + + return score switch + { + >= 0.9f => "critical", + >= 0.7f => "high", + >= 0.4f => "medium", + _ => "low" + }; + } + } +} diff --git a/MCPForUnity/Editor/ActionTrace/Semantics/DefaultEventScorer.cs b/MCPForUnity/Editor/ActionTrace/Semantics/DefaultEventScorer.cs new file mode 100644 index 000000000..a80b6f1ff --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Semantics/DefaultEventScorer.cs @@ -0,0 +1,107 @@ +using System; +using MCPForUnity.Editor.ActionTrace.Core; +using UnityEngine; + +namespace MCPForUnity.Editor.ActionTrace.Semantics +{ + /// + /// Default implementation of event importance scoring. + /// Scores are based on event type metadata, with special handling for payload-based adjustments. + /// + /// Scoring priority: + /// 1. Metadata.DefaultImportance (configured in EventTypes.Metadata) + /// 2. Payload-based adjustments (Script, Scene, Prefab detection) + /// 3. Dehydrated events (Payload is null) → 0.1f + /// + public sealed class DefaultEventScorer : IEventScorer + { + private static readonly Lazy _instance = new(() => new DefaultEventScorer()); + + /// + /// Singleton instance for use in EventStore importance filtering. + /// + public static DefaultEventScorer Instance => _instance.Value; + + /// + /// Calculate importance score for an event. + /// Higher scores indicate more significant events. + /// + /// Scoring strategy: + /// - Uses EventTypes.Metadata.Get() for base score + /// - Applies payload-based adjustments for assets (Script=+0.4, Scene=+0.2, Prefab=+0.3) + /// - Dehydrated events return 0.1f + /// + public float Score(EditorEvent evt) + { + // Dehydrated events (Payload is null) use low default score + if (evt.Payload == null) + return 0.1f; + + // Get base score from metadata + var meta = EventTypes.Metadata.Get(evt.Type); + float baseScore = meta.DefaultImportance; + + // Special case: AINote is always critical + if (evt.Type == "AINote") + return 1.0f; + + // Apply payload-based adjustments for asset events + float adjustment = GetPayloadAdjustment(evt); + return Mathf.Clamp01(baseScore + adjustment); + } + + /// + /// Calculate score adjustment based on payload content. + /// Used to boost/reduce scores for specific asset types. + /// + private static float GetPayloadAdjustment(EditorEvent evt) + { + if (evt.Payload == null) + return 0f; + + // Asset type adjustments (only for AssetCreated/AssetImported) + bool isAssetEvent = evt.Type == EventTypes.AssetCreated || + evt.Type == EventTypes.AssetImported; + + if (!isAssetEvent) + return 0f; + + if (IsScript(evt)) + return 0.4f; // Scripts are high priority + if (IsScene(evt)) + return 0.2f; + if (IsPrefab(evt)) + return 0.3f; + + return 0f; + } + + private static bool IsScript(EditorEvent e) + { + if (e.Payload.TryGetValue("extension", out var ext)) + return ext.ToString() == ".cs"; + if (e.Payload.TryGetValue("asset_type", out var type)) + return type.ToString()?.Contains("Script") == true || + type.ToString()?.Contains("MonoScript") == true; + return false; + } + + private static bool IsScene(EditorEvent e) + { + if (e.Payload.TryGetValue("extension", out var ext)) + return ext.ToString() == ".unity"; + if (e.Payload.TryGetValue("asset_type", out var type)) + return type.ToString()?.Contains("Scene") == true; + return false; + } + + private static bool IsPrefab(EditorEvent e) + { + if (e.Payload.TryGetValue("extension", out var ext)) + return ext.ToString() == ".prefab"; + if (e.Payload.TryGetValue("asset_type", out var type)) + return type.ToString()?.Contains("Prefab") == true; + return false; + } + } +} diff --git a/MCPForUnity/Editor/ActionTrace/Semantics/DefaultIntentInferrer.cs b/MCPForUnity/Editor/ActionTrace/Semantics/DefaultIntentInferrer.cs new file mode 100644 index 000000000..9b2506a5d --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Semantics/DefaultIntentInferrer.cs @@ -0,0 +1,164 @@ +using System; +using System.Collections.Generic; +using MCPForUnity.Editor.ActionTrace.Core; + +namespace MCPForUnity.Editor.ActionTrace.Semantics +{ + /// + /// Default implementation of intent inference. + /// Analyzes events to infer user intent based on type and context. + /// + public sealed class DefaultIntentInferrer : IIntentInferrer + { + /// + /// Infer the intent behind an event. + /// Uses event type and payload to determine user intent. + /// + public string Infer(EditorEvent evt, IReadOnlyList surrounding) + { + // For dehydrated events (Payload is null), intent cannot be inferred, return null + if (evt.Payload == null) + return null; + + // Normalize null to empty list for safe enumeration in helper methods + surrounding ??= Array.Empty(); + + return evt.Type switch + { + // Asset-related intents + EventTypes.AssetCreated or EventTypes.AssetImported when IsScript(evt) => "Coding", + EventTypes.AssetCreated or EventTypes.AssetImported when IsScene(evt) => "Creating Scene", + EventTypes.AssetCreated or EventTypes.AssetImported when IsTexture(evt) => "Importing Texture", + EventTypes.AssetCreated or EventTypes.AssetImported when IsAudio(evt) => "Importing Audio", + EventTypes.AssetCreated or EventTypes.AssetImported when IsPrefab(evt) => "Creating Prefab", + EventTypes.AssetCreated or EventTypes.AssetImported => "Importing Asset", + + // GameObject operations + EventTypes.GameObjectCreated => "Adding GameObject", + EventTypes.GameObjectDestroyed => "Removing GameObject", + + // Component operations + EventTypes.ComponentAdded when IsRigidBody(evt) => "Adding Physics Component", + EventTypes.ComponentAdded when IsCollider(evt) => "Adding Collider", + EventTypes.ComponentAdded when IsScript(evt) => "Attaching Script", + EventTypes.ComponentAdded => "Adding Component", + EventTypes.ComponentRemoved => "Removing Component", + + // Scene operations + EventTypes.SceneSaved => "Saving Scene", + EventTypes.SceneOpened => "Opening Scene", + EventTypes.NewSceneCreated => "Creating New Scene", + + // Build operations + EventTypes.BuildStarted => "Build Started", + EventTypes.BuildCompleted => "Build Completed", + EventTypes.BuildFailed => "Build Failed", + + // Script operations + EventTypes.ScriptCompiled => "Compiling Scripts", + EventTypes.ScriptCompilationFailed => "Script Compilation Failed", + + // Hierarchy operations + EventTypes.HierarchyChanged when IsReparenting(surrounding) => "Adjusting Hierarchy", + EventTypes.HierarchyChanged when IsBatchOperation(surrounding) => "Batch Operation", + EventTypes.HierarchyChanged => null, // Too frequent, don't infer + + _ => null + }; + } + + private static bool IsScript(EditorEvent e) + { + if (e.Payload.TryGetValue("extension", out var ext)) + return ext.ToString() == ".cs"; + if (e.Payload.TryGetValue("component_type", out var type)) + return type.ToString()?.Contains("MonoBehaviour") == true; + return false; + } + + private static bool IsScene(EditorEvent e) + { + if (e.Payload.TryGetValue("extension", out var ext)) + return ext.ToString() == ".unity"; + return false; + } + + private static bool IsPrefab(EditorEvent e) + { + if (e.Payload.TryGetValue("extension", out var ext)) + return ext.ToString() == ".prefab"; + return false; + } + + private static bool IsTexture(EditorEvent e) + { + if (e.Payload.TryGetValue("extension", out var ext)) + { + var extStr = ext.ToString(); + return extStr == ".png" || extStr == ".jpg" || extStr == ".jpeg" || + extStr == ".psd" || extStr == ".tga" || extStr == ".exr"; + } + if (e.Payload.TryGetValue("type", out var type)) + return type.ToString()?.Contains("Texture") == true; + return false; + } + + private static bool IsAudio(EditorEvent e) + { + if (e.Payload.TryGetValue("extension", out var ext)) + { + var extStr = ext.ToString(); + return extStr == ".wav" || extStr == ".mp3" || extStr == ".ogg" || + extStr == ".aif" || extStr == ".aiff"; + } + return false; + } + + private static bool IsRigidBody(EditorEvent e) + { + if (e.Payload.TryGetValue("component_type", out var type)) + { + var typeStr = type.ToString(); + return typeStr == "Rigidbody" || typeStr == "Rigidbody2D"; + } + return false; + } + + private static bool IsCollider(EditorEvent e) + { + if (e.Payload.TryGetValue("component_type", out var type)) + { + var typeStr = type.ToString(); + return typeStr?.Contains("Collider") == true; + } + return false; + } + + private static bool IsReparenting(IReadOnlyList surrounding) + { + // If there are multiple hierarchy changes in quick succession, + // it's likely a reparenting operation + int count = 0; + foreach (var e in surrounding) + { + if (e.Type == EventTypes.HierarchyChanged) count++; + if (count >= 3) return true; + } + return false; + } + + private static bool IsBatchOperation(IReadOnlyList surrounding) + { + // Many events of the same type suggest a batch operation + if (surrounding.Count < 5) return false; + + var firstType = surrounding[0].Type; + int sameTypeCount = 0; + foreach (var e in surrounding) + { + if (e.Type == firstType) sameTypeCount++; + } + return sameTypeCount >= 5; + } + } +} diff --git a/MCPForUnity/Editor/ActionTrace/Semantics/IEventCategorizer.cs b/MCPForUnity/Editor/ActionTrace/Semantics/IEventCategorizer.cs new file mode 100644 index 000000000..4438b0065 --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Semantics/IEventCategorizer.cs @@ -0,0 +1,17 @@ +namespace MCPForUnity.Editor.ActionTrace.Semantics +{ + /// + /// Event categorizer interface. + /// Converts importance scores into categorical labels. + /// Categories are computed at query time, not stored with events. + /// + public interface IEventCategorizer + { + /// + /// Categorize an importance score into a label. + /// + /// Importance score from 0.0 to 1.0 + /// Category label (e.g., "critical", "high", "medium", "low") + string Categorize(float score); + } +} diff --git a/MCPForUnity/Editor/ActionTrace/Semantics/IEventScorer.cs b/MCPForUnity/Editor/ActionTrace/Semantics/IEventScorer.cs new file mode 100644 index 000000000..0286a889a --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Semantics/IEventScorer.cs @@ -0,0 +1,20 @@ +using MCPForUnity.Editor.ActionTrace.Core; + +namespace MCPForUnity.Editor.ActionTrace.Semantics +{ + /// + /// Event importance scorer interface. + /// Returns a float score (0.0 to 1.0) representing event importance. + /// Scores are computed at query time, not stored with events. + /// + public interface IEventScorer + { + /// + /// Calculate importance score for an event. + /// Higher values indicate more important events. + /// + /// The event to score + /// Score from 0.0 (least important) to 1.0 (most important) + float Score(EditorEvent evt); + } +} diff --git a/MCPForUnity/Editor/ActionTrace/Semantics/IIntentInferrer.cs b/MCPForUnity/Editor/ActionTrace/Semantics/IIntentInferrer.cs new file mode 100644 index 000000000..9b42437fd --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Semantics/IIntentInferrer.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using MCPForUnity.Editor.ActionTrace.Core; + +namespace MCPForUnity.Editor.ActionTrace.Semantics +{ + /// + /// Intent inference interface. + /// Analyzes events to infer the user's intent or purpose. + /// Intents are computed at query time using surrounding event context. + /// + public interface IIntentInferrer + { + /// + /// Infer the intent behind an event. + /// May analyze surrounding events to determine context. + /// + /// The event to analyze + /// Surrounding events for context (may be empty) + /// Inferred intent description, or null if unable to infer + string Infer(EditorEvent evt, IReadOnlyList surrounding); + } +} diff --git a/MCPForUnity/Editor/ActionTrace/VCS/VcsContextProvider.cs b/MCPForUnity/Editor/ActionTrace/VCS/VcsContextProvider.cs new file mode 100644 index 000000000..1bab02bc5 --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/VCS/VcsContextProvider.cs @@ -0,0 +1,330 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using UnityEditor; +using UnityEngine; +using MCPForUnity.Editor.Helpers; + +namespace MCPForUnity.Editor.ActionTrace.VCS +{ + /// + /// Version Control System (VCS) integration for ActionTrace events. + /// + /// Purpose (from ActionTrace-enhancements.md P2.2): + /// - Track Git commit and branch information + /// - Mark events as "dirty" if they occurred after last commit + /// - Help AI understand "dirty state" (uncommitted changes) + /// + /// Implementation: + /// - Polls Git status periodically (via EditorApplication.delayCall) + /// - Injects vcs_context into event payloads + /// - Supports Git-only (Unity Collaborate, SVN, Perforce not implemented) + /// + /// Event payload format: + /// { + /// "sequence": 123, + /// "summary": "Added Rigidbody to Player", + /// "vcs_context": { + /// "commit_id": "abc123", + /// "branch": "feature/player-movement", + /// "is_dirty": true + /// } + /// } + /// + public static class VcsContextProvider + { + // Configuration + private const float PollIntervalSeconds = 5.0f; // Poll every 5 seconds + + // State + private static VcsContext _currentContext; + private static double _lastPollTime; + + /// + /// Initializes the VCS context provider and starts polling. + /// + static VcsContextProvider() + { + _currentContext = GetInitialContext(); + EditorApplication.update += OnUpdate; + } + + /// + /// Periodic update to refresh Git status. + /// + private static void OnUpdate() + { + if (EditorApplication.timeSinceStartup - _lastPollTime > PollIntervalSeconds) + { + RefreshContext(); + _lastPollTime = EditorApplication.timeSinceStartup; + } + } + + /// + /// Gets the current VCS context for event injection. + /// Thread-safe (called from any thread during event recording). + /// + public static VcsContext GetCurrentContext() + { + if (_currentContext == null) + { + _currentContext = GetInitialContext(); + } + + return _currentContext; + } + + /// + /// Refreshes the VCS context by polling Git status. + /// + private static void RefreshContext() + { + try + { + _currentContext = QueryGitStatus(); + } + catch (System.Exception ex) + { + McpLog.Warn($"[VcsContextProvider] Failed to query Git status: {ex.Message}"); + // Fall back to default context + _currentContext = VcsContext.CreateDefault(); + } + } + + /// + /// Queries Git status using git command. + /// Returns current commit, branch, and dirty state. + /// + private static VcsContext QueryGitStatus() + { + // Check if this is a Git repository + if (!IsGitRepository()) + { + return VcsContext.CreateDefault(); + } + + // Get current commit + var commitId = RunGitCommand("rev-parse HEAD"); + var shortCommit = commitId?.Length > 8 ? commitId.Substring(0, 8) : commitId; + + // Get current branch + var branch = RunGitCommand("rev-parse --abbrev-ref HEAD"); + + // Check if working tree is dirty + var statusOutput = RunGitCommand("status --porcelain"); + var isDirty = !string.IsNullOrEmpty(statusOutput); + + return new VcsContext + { + CommitId = shortCommit ?? "unknown", + Branch = branch ?? "unknown", + IsDirty = isDirty + }; + } + + /// + /// Gets initial VCS context on startup. + /// + private static VcsContext GetInitialContext() + { + try + { + return QueryGitStatus(); + } + catch + { + return VcsContext.CreateDefault(); + } + } + + /// + /// Checks if the current project is under Git version control. + /// + private static bool IsGitRepository() + { + try + { + var projectPath = System.IO.Path.GetDirectoryName(UnityEngine.Application.dataPath); + var gitPath = System.IO.Path.Combine(projectPath, ".git"); + + return System.IO.Directory.Exists(gitPath); + } + catch + { + return false; + } + } + + /// + /// Runs a Git command and returns stdout. + /// Returns null if command fails. + /// + private static string RunGitCommand(string arguments) + { + try + { + var projectPath = System.IO.Path.GetDirectoryName(UnityEngine.Application.dataPath); + var gitPath = System.IO.Path.Combine(projectPath, ".git"); + + // Find git executable + string gitExe = FindGitExecutable(); + if (string.IsNullOrEmpty(gitExe)) + return null; + + var startInfo = new ProcessStartInfo + { + FileName = gitExe, + Arguments = $"-C \"{projectPath}\" {arguments}", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + using (var process = Process.Start(startInfo)) + { + // Read both StandardOutput and StandardError simultaneously to avoid buffer blocking + var outputTask = System.Threading.Tasks.Task.Run(() => process.StandardOutput.ReadToEnd()); + var errorTask = System.Threading.Tasks.Task.Run(() => process.StandardError.ReadToEnd()); + + // Add timeout protection (5 seconds) to prevent editor freeze + if (!process.WaitForExit(5000)) + { + // Timeout exceeded - kill the process + try + { + process.Kill(); + // Wait for process to actually exit after Kill + process.WaitForExit(1000); + } + catch { } + McpLog.Warn("[VcsContextProvider] Git command timeout after 5 seconds"); + return null; + } + + // Wait for both read tasks to complete (with short timeout to avoid hanging) + if (!System.Threading.Tasks.Task.WaitAll(new[] { outputTask, errorTask }, 1000)) + { + McpLog.Warn("[VcsContextProvider] Git output read timeout"); + return null; + } + + var output = outputTask.Result; + var error = errorTask.Result; + + // Log if there is error output + if (!string.IsNullOrEmpty(error)) + { + McpLog.Warn($"[VcsContextProvider] Git error: {error.Trim()}"); + } + + return output.Trim(); + } + } + catch (System.Exception ex) + { + McpLog.Warn($"[VcsContextProvider] Git command failed: {ex.Message}"); + return null; + } + } + + /// + /// Finds the Git executable path. + /// + private static string FindGitExecutable() + { + // Try common Git locations + string[] gitPaths = new[] + { + @"C:\Program Files\Git\bin\git.exe", + @"C:\Program Files (x86)\Git\bin\git.exe", + "/usr/bin/git", + "/usr/local/bin/git" + }; + + foreach (var path in gitPaths) + { + if (System.IO.File.Exists(path)) + return path; + } + + // Try system PATH + try + { + var startInfo = new ProcessStartInfo + { + FileName = "git", + Arguments = "--version", + UseShellExecute = false, + RedirectStandardOutput = true, + CreateNoWindow = true + }; + + using (var process = Process.Start(startInfo)) + { + process.WaitForExit(); + if (process.ExitCode == 0) + return "git"; // Found in PATH + } + } + catch + { + // Git executable not found in PATH + } + + return null; + } + } + + /// + /// Represents the VCS context at the time of event recording. + /// + public sealed class VcsContext + { + /// + /// Current Git commit hash (short form, 8 characters). + /// Example: "abc12345" + /// + public string CommitId { get; set; } + + /// + /// Current Git branch name. + /// Example: "feature/player-movement", "main" + /// + public string Branch { get; set; } + + /// + /// Whether the working tree has uncommitted changes. + /// True if there are modified/new/deleted files not yet committed. + /// + public bool IsDirty { get; set; } + + /// + /// Creates a default Vcs context for non-Git repositories. + /// + public static VcsContext CreateDefault() + { + return new VcsContext + { + CommitId = "unknown", + Branch = "unknown", + IsDirty = false + }; + } + + /// + /// Converts this context to a dictionary for event payload injection. + /// Returns a new dictionary on each call to prevent unintended mutations. + /// + public Dictionary ToDictionary() + { + return new Dictionary + { + ["commit_id"] = CommitId, + ["branch"] = Branch, + ["is_dirty"] = IsDirty + }; + } + } +} diff --git a/MCPForUnity/Editor/Helpers/BuildTargetUtility.cs b/MCPForUnity/Editor/Helpers/BuildTargetUtility.cs new file mode 100644 index 000000000..7788701d6 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/BuildTargetUtility.cs @@ -0,0 +1,61 @@ +using UnityEditor; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Helper for getting human-readable build target names. + /// Converts Unity's BuildTarget enum to user-friendly platform names. + /// + internal static class BuildTargetUtility + { + /// + /// Gets a human-readable name for a BuildTarget. + /// + public static string GetBuildTargetName(BuildTarget target) + { + return target switch + { + BuildTarget.StandaloneWindows => "Windows", + BuildTarget.StandaloneWindows64 => "Windows64", + BuildTarget.StandaloneOSX => "macOS", + BuildTarget.StandaloneLinux64 => "Linux64", + BuildTarget.Android => "Android", + BuildTarget.iOS => "iOS", + BuildTarget.WebGL => "WebGL", + BuildTarget.WSAPlayer => "UWP", + BuildTarget.PS4 => "PS4", + BuildTarget.PS5 => "PS5", + BuildTarget.XboxOne => "Xbox One", + BuildTarget.Switch => "Switch", + BuildTarget.tvOS => "tvOS", + BuildTarget.NoTarget => "No Target", + _ => target.ToString() + }; + } + + /// + /// Gets the BuildTarget from a platform name string. + /// Reverse of GetBuildTargetName. + /// + public static BuildTarget? ParseBuildTarget(string platformName) + { + return platformName?.ToLowerInvariant() switch + { + "windows" => BuildTarget.StandaloneWindows, + "windows64" => BuildTarget.StandaloneWindows64, + "macos" => BuildTarget.StandaloneOSX, + "linux" or "linux64" => BuildTarget.StandaloneLinux64, + "android" => BuildTarget.Android, + "ios" => BuildTarget.iOS, + "webgl" => BuildTarget.WebGL, + "uwp" => BuildTarget.WSAPlayer, + "ps4" => BuildTarget.PS4, + "ps5" => BuildTarget.PS5, + "xboxone" or "xbox" => BuildTarget.XboxOne, + "switch" => BuildTarget.Switch, + "tvos" => BuildTarget.tvOS, + _ => null + }; + } + } +} diff --git a/MCPForUnity/Editor/Helpers/GlobalIdHelper.cs b/MCPForUnity/Editor/Helpers/GlobalIdHelper.cs new file mode 100644 index 000000000..3af19a983 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/GlobalIdHelper.cs @@ -0,0 +1,277 @@ +using System; +using UnityEditor; +using UnityEngine; +using MCPForUnity.Editor.Helpers; + +namespace MCPForUnity.Editor.ActionTrace.Core +{ + /// + /// Cross-session stable object identifier for ActionTrace events. + /// + /// Uses Unity's GlobalObjectId (2020.3+) with fallback to Scene/Asset paths. + /// This ensures that TargetId references survive domain reloads and editor restarts. + /// + /// Reuses existing Helpers: + /// - GameObjectLookup.GetGameObjectPath() for Scene fallback paths + /// - GameObjectLookup.FindById() for legacy InstanceID resolution + /// + public static class GlobalIdHelper + { + /// + /// Prefix for fallback path format when GlobalObjectId is unavailable. + /// Format: "Scene:{scenePath}@{hierarchyPath}" or "Asset:{assetPath}" + /// + private const string ScenePrefix = "Scene:"; + private const string AssetPrefix = "Asset:"; + private const string InstancePrefix = "Instance:"; + private const string PathSeparator = "@"; + + /// + /// Converts a UnityEngine.Object to a cross-session stable ID string. + /// + /// Priority: + /// 1. GlobalObjectId (Unity 2020.3+) - Most stable + /// 2. Scene path + hierarchy path (for GameObjects in scenes) + /// 3. Asset path (for assets in Project view) + /// 4. InstanceID (last resort - not cross-session stable) + /// + public static string ToGlobalIdString(UnityEngine.Object obj) + { + if (obj == null) + return string.Empty; + +#if UNITY_2020_3_OR_NEWER + // Priority 1: Use Unity's built-in GlobalObjectId (most stable) + var globalId = GlobalObjectId.GetGlobalObjectIdSlow(obj); + // identifierType == 0 means invalid (not a scene object or asset) + if (globalId.identifierType != 0) + { + return globalId.ToString(); + } + // Fall through to fallback if GlobalObjectId is invalid +#endif + + // Priority 2 & 3: Use fallback paths (reuses GameObjectLookup) + return GetFallbackId(obj); + } + + /// + /// Attempts to resolve a GlobalId string back to a Unity object. + /// Returns null if the object no longer exists or the ID is invalid. + /// + public static UnityEngine.Object FromGlobalIdString(string globalIdStr) + { + if (string.IsNullOrEmpty(globalIdStr)) + return null; + +#if UNITY_2020_3_OR_NEWER + // Try parsing as GlobalObjectId first + if (GlobalObjectId.TryParse(globalIdStr, out var globalId)) + { + var obj = GlobalObjectId.GlobalObjectIdentifierToObjectSlow(globalId); + if (obj != null) + return obj; + } +#endif + + // Try parsing fallback formats + return ParseFallbackId(globalIdStr); + } + + /// + /// Generates a fallback ID when GlobalObjectId is unavailable. + /// + /// Reuses existing Helpers: + /// - GameObjectLookup.GetGameObjectPath() for Scene GameObject paths + /// + /// Formats: + /// - Scene GameObject: "Scene:Assets/MyScene.unity@GameObject/Child/Target" + /// - Asset: "Asset:Assets/Prefabs/MyPrefab.prefab" + /// - Other: "Instance:12345" (not cross-session stable) + /// + private static string GetFallbackId(UnityEngine.Object obj) + { + // GameObjects in valid scenes: use scene path + hierarchy path + if (obj is GameObject go && go.scene.IsValid()) + { + // Reuse GameObjectLookup.GetGameObjectPath() + string hierarchyPath = GameObjectLookup.GetGameObjectPath(go); + return $"{ScenePrefix}{go.scene.path}{PathSeparator}{hierarchyPath}"; + } + + // Assets (ScriptableObject, Material, Texture, etc.): use AssetDatabase + string assetPath = AssetDatabase.GetAssetPath(obj); + if (!string.IsNullOrEmpty(assetPath)) + { + return $"{AssetPrefix}{assetPath}"; + } + + // Last resort: InstanceID (not cross-session stable) + return $"{InstancePrefix}{obj.GetInstanceID()}"; + } + + /// + /// Parses a fallback ID string back to a Unity object. + /// Handles Scene, Asset, and Instance formats. + /// + private static UnityEngine.Object ParseFallbackId(string idStr) + { + if (string.IsNullOrEmpty(idStr)) + return null; + + // Format: "Scene:{scenePath}@{hierarchyPath}" + if (idStr.StartsWith(ScenePrefix)) + { + int separatorIndex = idStr.IndexOf(PathSeparator); + if (separatorIndex > 0) + { + string scenePath = idStr.Substring(ScenePrefix.Length, separatorIndex - ScenePrefix.Length); + string hierarchyPath = idStr.Substring(separatorIndex + 1); + + // Load the scene if not already loaded + var scene = UnityEditor.SceneManagement.EditorSceneManager.GetSceneByPath(scenePath); + if (!scene.IsValid()) + { + // Scene not loaded - cannot resolve + return null; + } + + // Find GameObject by hierarchy path + var found = GameObject.Find(hierarchyPath); + return found; + } + } + + // Format: "Asset:{assetPath}" + if (idStr.StartsWith(AssetPrefix)) + { + string assetPath = idStr.Substring(AssetPrefix.Length); + return AssetDatabase.LoadMainAssetAtPath(assetPath); + } + + // Format: "Instance:{instanceId}" + // Reuse GameObjectLookup.FindById() + if (idStr.StartsWith(InstancePrefix)) + { + string instanceStr = idStr.Substring(InstancePrefix.Length); + if (int.TryParse(instanceStr, out int instanceId)) + { + return GameObjectLookup.FindById(instanceId); + } + } + + return null; + } + + /// + /// Extracts a human-readable display name from a GlobalId string. + /// Useful for ActionTrace Viewer UI display. + /// Returns the object name if resolvable, otherwise a formatted ID string. + /// + public static string GetDisplayName(string globalIdStr) + { + if (string.IsNullOrEmpty(globalIdStr)) + return ""; + + // Try to resolve the object + var obj = FromGlobalIdString(globalIdStr); + if (obj != null) + return obj.name; + + // Object not found, extract readable parts from ID +#if UNITY_2020_3_OR_NEWER + if (GlobalObjectId.TryParse(globalIdStr, out var globalId)) + { + var guidStr = globalId.assetGUID.ToString(); + return guidStr.Length >= 8 + ? $"[{globalId.identifierType} {guidStr.Substring(0, 8)}...]" + : $"[{globalId.identifierType} {guidStr}]"; + } +#endif + + // Fallback format + if (globalIdStr.StartsWith(ScenePrefix)) + { + int separatorIndex = globalIdStr.IndexOf(PathSeparator); + if (separatorIndex > 0) + { + string hierarchyPath = globalIdStr.Substring(separatorIndex + 1); + // Extract just the object name (last part of path) + int lastSlash = hierarchyPath.LastIndexOf('/'); + return lastSlash >= 0 + ? hierarchyPath.Substring(lastSlash + 1) + : hierarchyPath; + } + } + + if (globalIdStr.StartsWith(AssetPrefix)) + { + string assetPath = globalIdStr.Substring(AssetPrefix.Length); + // Extract just the filename + int lastSlash = assetPath.LastIndexOf('/'); + return lastSlash >= 0 + ? assetPath.Substring(lastSlash + 1) + : assetPath; + } + + // Truncate long IDs for display + if (globalIdStr.Length > 50) + return globalIdStr.Substring(0, 47) + "..."; + + return globalIdStr; + } + + /// + /// Checks if a GlobalId string is valid (non-null and non-empty). + /// + public static bool IsValidId(string globalIdStr) + { + return !string.IsNullOrEmpty(globalIdStr); + } + + /// + /// Gets the type of an ID string (GlobalObjectId, Scene, Asset, Instance). + /// Useful for debugging and categorization. + /// + public static GlobalIdType GetIdType(string globalIdStr) + { + if (string.IsNullOrEmpty(globalIdStr)) + return GlobalIdType.Invalid; + +#if UNITY_2020_3_OR_NEWER + if (GlobalObjectId.TryParse(globalIdStr, out var globalId)) + return GlobalIdType.GlobalObjectId; +#endif + + if (globalIdStr.StartsWith(ScenePrefix)) + return GlobalIdType.ScenePath; + + if (globalIdStr.StartsWith(AssetPrefix)) + return GlobalIdType.AssetPath; + + if (globalIdStr.StartsWith(InstancePrefix)) + return GlobalIdType.InstanceId; + + return GlobalIdType.Unknown; + } + } + + /// + /// Type classification for GlobalId strings. + /// + public enum GlobalIdType + { + /// Null or empty string + Invalid, + /// Unity 2020.3+ GlobalObjectId format + GlobalObjectId, + /// "Scene:{path}@{hierarchy}" fallback format + ScenePath, + /// "Asset:{path}" fallback format + AssetPath, + /// "Instance:{id}" fallback format (not cross-session stable) + InstanceId, + /// Unknown format + Unknown + } +} diff --git a/MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs b/MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs index c280d9559..74f122b36 100644 --- a/MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs +++ b/MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs @@ -32,5 +32,11 @@ public static void ShowEditorPrefsWindow() { EditorPrefsWindow.ShowWindow(); } + + [MenuItem("Window/MCP For Unity/ActionTrace", priority = 4)] + public static void ShowActionTraceWindow() + { + ActionTraceEditorWindow.ShowWindow(); + } } } diff --git a/MCPForUnity/Editor/Resources/ActionTrace/ActionTraceViewResource.cs b/MCPForUnity/Editor/Resources/ActionTrace/ActionTraceViewResource.cs new file mode 100644 index 000000000..434a78e37 --- /dev/null +++ b/MCPForUnity/Editor/Resources/ActionTrace/ActionTraceViewResource.cs @@ -0,0 +1,664 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.ActionTrace.Context; +using MCPForUnity.Editor.ActionTrace.Core; +using MCPForUnity.Editor.ActionTrace.Query; +using MCPForUnity.Editor.ActionTrace.Semantics; +using Newtonsoft.Json.Linq; +using static MCPForUnity.Editor.ActionTrace.Query.ActionTraceQuery; + +namespace MCPForUnity.Editor.Resources.ActionTrace +{ + /// + /// Response wrapper constants for ActionTraceView. + /// Ensures consistent schema versioning across all response types. + /// + internal static class ResponseSchemas + { + public const string Basic = "action_trace_view@1"; + public const string WithContext = "action_trace_view@2"; + public const string WithSemantics = "action_trace_view@3"; + public const string Aggregated = "action_trace_view@4"; + } + + /// + /// Event type constants for filtering. + /// + internal static class EventTypes + { + public const string AINote = "AINote"; + } + + /// + /// MCP resource for querying the action trace of editor events. + /// + /// URI: mcpforunity://action_trace_view + /// + /// Parameters: + /// - limit: Maximum number of events to return (default: 50) + /// - since_sequence: Only return events after this sequence number + /// - include_context: If true, include context associations (default: false) + /// - include_semantics: If true, include importance, category, intent (default: false) + /// - min_importance: Minimum importance score to include (default: "medium") + /// Options: "low" (0.0+), "medium" (0.4+), "high" (0.7+), "critical" (0.9+) + /// - source: Filter by operation source: "ai", "human", "system" (optional) + /// + /// L3 Semantic Whitelist: + /// By default, only events with importance >= 0.4 (medium+) are returned. + /// To include low-importance events like HierarchyChanged, specify min_importance="low". + /// + [McpForUnityResource("action_trace_view")] + public static class ActionTraceViewResource + { + public static object HandleCommand(JObject @params) + { + try + { + int limit = GetLimit(@params); + long? sinceSequence = GetSinceSequence(@params); + bool includeContext = GetIncludeContext(@params); + bool includeSemantics = GetIncludeSemantics(@params); + string sourceFilter = GetSourceFilter(@params); + + // L3 Semantic Whitelist: Parse minimum importance threshold + float minImportance = GetMinImportance(@params); + + // P1.2 Task-Level Filtering: Parse task and conversation IDs + string taskId = GetTaskId(@params); + string conversationId = GetConversationId(@params); + + // P1.1 Transaction Aggregation: Parse summary_only parameter + bool summaryOnly = GetSummaryOnly(@params); + + // If summary_only is requested, return aggregated transactions + if (summaryOnly) + { + return QueryAggregated(limit, sinceSequence, minImportance, taskId, conversationId); + } + + // Decide query mode based on parameters + bool useContextQuery = includeContext || !string.IsNullOrEmpty(sourceFilter); + + if (useContextQuery) + { + return QueryWithContext(limit, sinceSequence, sourceFilter, includeSemantics, minImportance, taskId, conversationId); + } + + if (includeSemantics) + { + return QueryWithSemanticsOnly(limit, sinceSequence, minImportance, taskId, conversationId); + } + + // Basic query without context or semantics (apply importance filter anyway) + return QueryBasic(limit, sinceSequence, minImportance, taskId, conversationId); + } + catch (Exception ex) + { + McpLog.Error($"[ActionTraceViewResource] Error: {ex.Message}"); + return new ErrorResponse($"Error retrieving ActionTrace: {ex.Message}"); + } + } + + /// + /// Basic query without context or semantics + /// Applies L3 importance filter by default (medium+ importance). + /// P1.2: Supports task_id and conversation_id filtering. + /// + private static object QueryBasic(int limit, long? sinceSequence, float minImportance, string taskId, string conversationId) + { + var events = EventStore.Query(limit, sinceSequence); + + // Apply disabled event types filter + events = ApplyDisabledTypesFilter(events); + + // L3 Semantic Whitelist: Filter by importance + var scorer = new DefaultEventScorer(); + var filteredEvents = events + .Where(e => scorer.Score(e) >= minImportance) + .ToList(); + + // P1.2 Task-Level Filtering: Filter by task_id and conversation_id + filteredEvents = ApplyTaskFilters(filteredEvents, taskId, conversationId); + + var eventItems = filteredEvents.Select(e => new + { + sequence = e.Sequence, + timestamp_unix_ms = e.TimestampUnixMs, + type = e.Type, + target_id = e.TargetId, + summary = e.GetSummary() + }).ToArray(); + + return new SuccessResponse("Retrieved ActionTrace events.", new + { + schema_version = ResponseSchemas.Basic, + events = eventItems, + total_count = eventItems.Length, + current_sequence = EventStore.CurrentSequence + }); + } + + /// + /// Query with semantics but without context + /// Applies L3 importance filter by default. + /// P1.2: Supports task_id and conversation_id filtering. + /// + private static object QueryWithSemanticsOnly(int limit, long? sinceSequence, float minImportance, string taskId, string conversationId) + { + var rawEvents = EventStore.Query(limit, sinceSequence); + + // Apply disabled event types filter + rawEvents = ApplyDisabledTypesFilter(rawEvents); + + var query = new ActionTraceQuery(); + var projected = query.Project(rawEvents); + + // L3 Semantic Whitelist: Filter by importance + var filtered = projected + .Where(p => p.ImportanceScore >= minImportance) + .ToList(); + + // P1.2 Task-Level Filtering: Filter by task_id and conversation_id + filtered = ApplyTaskFiltersToProjected(filtered, taskId, conversationId); + + var eventItems = filtered.Select(p => new + { + sequence = p.Event.Sequence, + timestamp_unix_ms = p.Event.TimestampUnixMs, + type = p.Event.Type, + target_id = p.Event.TargetId, + summary = p.Event.GetSummary(), + importance_score = p.ImportanceScore, + importance_category = p.ImportanceCategory, + inferred_intent = p.InferredIntent + }).ToArray(); + + return new SuccessResponse("Retrieved ActionTrace events with semantics.", new + { + schema_version = ResponseSchemas.WithSemantics, + events = eventItems, + total_count = eventItems.Length, + current_sequence = EventStore.CurrentSequence + }); + } + + /// + /// Query with context and optional semantics + /// Note: Source filtering requires persistent OperationContext storage. + /// + private static object QueryWithContext(int limit, long? sinceSequence, string sourceFilter, bool includeSemantics, float minImportance, string taskId, string conversationId) + { + // Check if source filter is requested (requires persistent context storage) + if (!string.IsNullOrEmpty(sourceFilter)) + { + return new ErrorResponse( + "Source filtering requires persistent OperationContext storage. " + + "The 'source' parameter is not yet supported." + ); + } + + var eventsWithContext = EventStore.QueryWithContext(limit, sinceSequence); + + // P1.2 Task-Level Filtering: Apply task_id and conversation_id filters + eventsWithContext = ApplyTaskFiltersToEventWithContext(eventsWithContext, taskId, conversationId); + + // Apply semantics if requested + if (includeSemantics) + { + var query = new ActionTraceQuery(); + var projected = query.ProjectWithContext(eventsWithContext); + + // Apply disabled event types filter + projected = ApplyDisabledTypesFilterToProjected(projected); + + // L3 Semantic Whitelist: Filter by importance + var filtered = projected + .Where(p => p.ImportanceScore >= minImportance) + .ToList(); + + var events = filtered.Select(p => + { + var hasContext = p.Context != null; + var contextId = hasContext ? p.Context.ContextId.ToString() : null; + + return new + { + sequence = p.Event.Sequence, + timestamp_unix_ms = p.Event.TimestampUnixMs, + type = p.Event.Type, + target_id = p.Event.TargetId, + summary = p.Event.GetSummary(), + has_context = hasContext, + context = hasContext ? new { context_id = contextId } : null, + importance_score = p.ImportanceScore, + importance_category = p.ImportanceCategory, + inferred_intent = p.InferredIntent + }; + }).ToArray(); + + return new SuccessResponse("Retrieved ActionTrace events with context and semantics.", new + { + schema_version = ResponseSchemas.WithSemantics, + events = events, + total_count = events.Length, + current_sequence = EventStore.CurrentSequence, + context_mapping_count = EventStore.ContextMappingCount + }); + } + else + { + // Apply disabled event types filter first + var settings = ActionTraceSettings.Instance; + var disabledTypes = settings != null ? settings.Filtering.DisabledEventTypes : null; + if (disabledTypes != null && disabledTypes.Length > 0) + { + eventsWithContext = eventsWithContext + .Where(x => !IsEventTypeDisabled(x.Event.Type, disabledTypes)) + .ToList(); + } + + // Context only response - apply importance filter manually + var scorer = new DefaultEventScorer(); + var filtered = eventsWithContext + .Where(x => scorer.Score(x.Event) >= minImportance) + .ToList(); + + var events = filtered.Select(x => + { + var hasContext = x.Context != null; + var contextId = hasContext ? x.Context.ContextId.ToString() : null; + + return new + { + sequence = x.Event.Sequence, + timestamp_unix_ms = x.Event.TimestampUnixMs, + type = x.Event.Type, + target_id = x.Event.TargetId, + summary = x.Event.GetSummary(), + has_context = hasContext, + context = hasContext ? new { context_id = contextId } : null + }; + }).ToArray(); + + return new SuccessResponse("Retrieved ActionTrace events with context.", new + { + schema_version = ResponseSchemas.WithContext, + events = events, + total_count = events.Length, + current_sequence = EventStore.CurrentSequence, + context_mapping_count = EventStore.ContextMappingCount + }); + } + } + + /// + /// P1.1: Query with transaction aggregation. + /// + /// Returns AtomicOperation list instead of raw events. + /// Reduces token consumption by grouping related events. + /// + /// From ActionTrace-enhancements.md line 294-300: + /// "summary_only=True 时返回 AtomicOperation 列表而非原始事件" + /// + private static object QueryAggregated(int limit, long? sinceSequence, float minImportance, string taskId, string conversationId) + { + // Step 1: Query raw events + var events = EventStore.Query(limit, sinceSequence); + + // Step 2: Apply disabled event types filter + events = ApplyDisabledTypesFilter(events); + + // Step 3: Apply importance filter (L3 Semantic Whitelist) + var scorer = new DefaultEventScorer(); + var filteredEvents = events + .Where(e => scorer.Score(e) >= minImportance) + .ToList(); + + // Step 4: Apply task-level filtering (P1.2) + filteredEvents = ApplyTaskFilters(filteredEvents, taskId, conversationId); + + // Step 4: Aggregate into transactions + var operations = TransactionAggregator.Aggregate(filteredEvents); + + // Step 5: Project to response format + var eventItems = operations.Select(op => new + { + start_sequence = op.StartSequence, + end_sequence = op.EndSequence, + summary = op.Summary, + event_count = op.EventCount, + duration_ms = op.DurationMs, + tool_call_id = op.ToolCallId, + triggered_by_tool = op.TriggeredByTool + }).ToArray(); + + return new SuccessResponse($"Retrieved {eventItems.Length} aggregated operations.", new + { + schema_version = ResponseSchemas.Aggregated, + events = eventItems, + total_count = eventItems.Length, + current_sequence = EventStore.CurrentSequence + }); + } + + private static int GetLimit(JObject @params) + { + var limitToken = @params["limit"] ?? @params["count"]; + if (limitToken != null && int.TryParse(limitToken.ToString(), out int limit)) + { + return Math.Clamp(limit, 1, 1000); + } + return 50; // Default + } + + private static long? GetSinceSequence(JObject @params) + { + var sinceToken = @params["since_sequence"] ?? @params["sinceSequence"] ?? @params["since"]; + if (sinceToken != null && long.TryParse(sinceToken.ToString(), out long since)) + { + return since; + } + return null; + } + + private static bool GetIncludeContext(JObject @params) + { + var includeToken = @params["include_context"] ?? @params["includeContext"]; + if (includeToken != null) + { + if (bool.TryParse(includeToken.ToString(), out bool include)) + { + return include; + } + } + return false; + } + + private static string GetSourceFilter(JObject @params) + { + var sourceToken = @params["source"] ?? @params["operation_source"]; + return sourceToken?.ToString(); + } + + private static bool GetIncludeSemantics(JObject @params) + { + var includeToken = @params["include_semantics"] ?? @params["includeSemantics"]; + if (includeToken != null) + { + if (bool.TryParse(includeToken.ToString(), out bool include)) + { + return include; + } + } + return false; + } + + /// + /// L3 Semantic Whitelist: Parse minimum importance threshold. + /// + /// Default: "medium" (0.4) - filters out low-importance noise like HierarchyChanged + /// + /// Options: + /// - "low" or 0.0: Include all events + /// - "medium" or 0.4: Include meaningful operations (default) + /// - "high" or 0.7: Include only significant changes + /// - "critical" or 0.9: Include only critical events (build failures, AI notes) + /// + /// Returns: float threshold for importance filtering + /// + private static float GetMinImportance(JObject @params) + { + var importanceToken = @params["min_importance"] ?? @params["minImportance"]; + if (importanceToken != null) + { + string importanceStr = importanceToken.ToString()?.ToLower()?.Trim(); + + // Parse string values + if (!string.IsNullOrEmpty(importanceStr)) + { + return importanceStr switch + { + "low" => 0.0f, + "medium" => 0.4f, + "high" => 0.7f, + "critical" => 0.9f, + _ => float.TryParse(importanceStr, out float val) ? val : 0.4f + }; + } + } + + // Default to medium importance (L3 Semantic Whitelist active by default) + return 0.4f; + } + + /// + /// P1.2: Parse task_id parameter. + /// + private static string GetTaskId(JObject @params) + { + var token = @params["task_id"] ?? @params["taskId"]; + return token?.ToString(); + } + + /// + /// P1.2: Parse conversation_id parameter. + /// + private static string GetConversationId(JObject @params) + { + var token = @params["conversation_id"] ?? @params["conversationId"]; + return token?.ToString(); + } + + /// + /// P1.2: Apply task_id and conversation_id filters to raw event list. + /// Filters AINote events by matching task_id and conversation_id in payload. + /// + private static List ApplyTaskFilters(List events, string taskId, string conversationId) + { + // If no filters specified, return original list + if (string.IsNullOrEmpty(taskId) && string.IsNullOrEmpty(conversationId)) + return events; + + return events.Where(e => + { + // Only AINote events have task_id and conversation_id + if (e.Type != EventTypes.AINote) + return true; + + // Guard against dehydrated events (null payload) + if (e.Payload == null) + return false; // Can't match filters without payload + + // Check task_id filter + if (!string.IsNullOrEmpty(taskId)) + { + if (e.Payload.TryGetValue("task_id", out var taskVal)) + { + string eventTaskId = taskVal?.ToString(); + if (eventTaskId != taskId) + return false; + } + else + { + return false; + } + } + + // Check conversation_id filter + if (!string.IsNullOrEmpty(conversationId)) + { + if (e.Payload.TryGetValue("conversation_id", out var convVal)) + { + string eventConvId = convVal?.ToString(); + if (eventConvId != conversationId) + return false; + } + } + + return true; + }).ToList(); + } + + /// + /// P1.2: Apply task filters to projected events (with semantics). + /// + private static List ApplyTaskFiltersToProjected(List projected, string taskId, string conversationId) + { + if (string.IsNullOrEmpty(taskId) && string.IsNullOrEmpty(conversationId)) + return projected; + + return projected.Where(p => + { + if (p.Event.Type != EventTypes.AINote) + return true; + + // Guard against dehydrated events (null payload) + if (p.Event.Payload == null) + return false; // Can't match filters without payload + + if (!string.IsNullOrEmpty(taskId)) + { + if (p.Event.Payload.TryGetValue("task_id", out var taskVal)) + { + if (taskVal?.ToString() != taskId) + return false; + } + else + { + return false; + } + } + + if (!string.IsNullOrEmpty(conversationId)) + { + if (p.Event.Payload.TryGetValue("conversation_id", out var convVal)) + { + if (convVal?.ToString() != conversationId) + return false; + } + } + + return true; + }).ToList(); + } + + /// + /// P1.2: Apply task filters to events with context tuples. + /// Filters AINote events by matching task_id and conversation_id in payload. + /// + private static IReadOnlyList<(EditorEvent Event, ContextMapping Context)> ApplyTaskFiltersToEventWithContext( + IReadOnlyList<(EditorEvent Event, ContextMapping Context)> eventsWithContext, + string taskId, + string conversationId) + { + // If no filters specified, return original list + if (string.IsNullOrEmpty(taskId) && string.IsNullOrEmpty(conversationId)) + return eventsWithContext; + + return eventsWithContext.Where(x => + { + var e = x.Event; + + // Only AINote events have task_id and conversation_id + if (e.Type != EventTypes.AINote) + return true; + + // Guard against dehydrated events (null payload) + if (e.Payload == null) + return false; // Can't match filters without payload + + // Check task_id filter + if (!string.IsNullOrEmpty(taskId)) + { + if (e.Payload.TryGetValue("task_id", out var taskVal)) + { + string eventTaskId = taskVal?.ToString(); + if (eventTaskId != taskId) + return false; + } + else + { + return false; + } + } + + // Check conversation_id filter + if (!string.IsNullOrEmpty(conversationId)) + { + if (e.Payload.TryGetValue("conversation_id", out var convVal)) + { + string eventConvId = convVal?.ToString(); + if (eventConvId != conversationId) + return false; + } + } + + return true; + }).ToList(); + } + + /// + /// P1.1: Parse summary_only parameter. + /// + private static bool GetSummaryOnly(JObject @params) + { + var token = @params["summary_only"] ?? @params["summaryOnly"]; + if (token != null) + { + if (bool.TryParse(token.ToString(), out bool summaryOnly)) + { + return summaryOnly; + } + } + return false; // Default to false + } + + /// + /// Filter out disabled event types from the event list. + /// This ensures that events recorded before a type was disabled are also filtered out. + /// + private static IReadOnlyList ApplyDisabledTypesFilter(IReadOnlyList events) + { + var settings = ActionTraceSettings.Instance; + if (settings == null) + return events; + + var disabledTypes = settings.Filtering.DisabledEventTypes; + if (disabledTypes == null || disabledTypes.Length == 0) + return events; + + return events.Where(e => !IsEventTypeDisabled(e.Type, disabledTypes)).ToList(); + } + + /// + /// Check if an event type is in the disabled types list. + /// + private static bool IsEventTypeDisabled(string eventType, string[] disabledTypes) + { + foreach (string disabled in disabledTypes) + { + if (string.Equals(eventType, disabled, StringComparison.Ordinal)) + return true; + } + return false; + } + + /// + /// Filter out disabled event types from projected events. + /// + private static IReadOnlyList ApplyDisabledTypesFilterToProjected(IReadOnlyList projected) + { + var settings = ActionTraceSettings.Instance; + if (settings == null) + return projected; + + var disabledTypes = settings.Filtering.DisabledEventTypes; + if (disabledTypes == null || disabledTypes.Length == 0) + return projected; + + return projected.Where(p => !IsEventTypeDisabled(p.Event.Type, disabledTypes)).ToList(); + } + } +} diff --git a/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs b/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs index 30e31958e..4db2b365e 100644 --- a/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs +++ b/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs @@ -57,6 +57,7 @@ public static class StdioBridgeHost private static int mainThreadId; private static int currentUnityPort = 6400; private static bool isAutoConnectMode = false; + private static volatile bool _isReloading = false; // Explicit flag for domain reload state private const ulong MaxFrameBytes = 64UL * 1024 * 1024; private const int FrameIOTimeoutMs = 30000; @@ -168,6 +169,48 @@ static StdioBridgeHost() ScheduleInitRetry(); } }; + + // CRITICAL FIX: Stop listener BEFORE domain reload to prevent zombie listeners + // Domain reload does NOT trigger EditorApplication.quitting, so we must + // explicitly stop here. This prevents the old domain's listener from + // remaining bound to the port after the new domain loads. + try + { + var assemblyReloadEventsType = Type.GetType("UnityEditor.AssemblyReloadEvents, UnityEditor"); + if (assemblyReloadEventsType != null) + { + var beforeAssemblyReloadEvent = assemblyReloadEventsType.GetEvent("beforeAssemblyReload"); + if (beforeAssemblyReloadEvent != null) + { + beforeAssemblyReloadEvent.AddEventHandler(null, new System.Action(OnBeforeAssemblyReload)); + if (IsDebugEnabled()) + McpLog.Info("[StdioBridgeHost] Registered beforeAssemblyReload handler"); + } + } + } + catch { } + } + + /// + /// Called immediately before Unity performs a domain reload (script compilation). + /// This is CRITICAL for preventing zombie listeners because EditorApplication.quitting + /// is NOT triggered during domain reload. + /// + private static void OnBeforeAssemblyReload() + { + if (IsDebugEnabled()) + McpLog.Info("[StdioBridgeHost] beforeAssemblyReload: stopping listener to prevent zombie"); + + // Set explicit reload flag BEFORE calling Stop() + // This ensures Stop() knows we're in a domain reload, even if + // EditorApplication.isCompiling isn't true yet (timing issue) + _isReloading = true; + + // Stop the listener BEFORE domain reload + Stop(); + + // Write heartbeat status AFTER Stop() so it won't be immediately deleted + WriteHeartbeat(true, "domain_reload"); } private static void InitializeAfterCompilation() @@ -293,9 +336,11 @@ public static void Start() Stop(); + // Get the port before starting + currentUnityPort = PortManager.GetPortWithFallback(); + try { - currentUnityPort = PortManager.GetPortWithFallback(); LogBreadcrumb("Start"); @@ -389,16 +434,17 @@ private static TcpListener CreateConfiguredListener(int port) { var newListener = new TcpListener(IPAddress.Loopback, port); #if !UNITY_EDITOR_OSX - newListener.Server.SetSocketOption( - SocketOptionLevel.Socket, - SocketOptionName.ReuseAddress, - true - ); + // NOTE: We do NOT set ReuseAddress=true to prevent zombie listeners from domain reload. + // Without SO_REUSEADDRESS, if an old listener is still bound, the new bind will fail + // with AddressAlreadyInUse, and the retry logic will wait for the OS to clean up the old socket. + // This is safer than allowing multiple listeners on the same port (zombie problem). + // macOS still needs ReuseAddress for proper domain reload behavior. #endif #if UNITY_EDITOR_WIN try { - newListener.ExclusiveAddressUse = false; + // Explicitly enable exclusive address use to prevent multiple bindings + newListener.ExclusiveAddressUse = true; } catch { } #endif @@ -442,6 +488,17 @@ public static void Stop() } } + // Clear the resume flag when stopping for reasons other than domain reload. + // This prevents the UI from getting stuck showing "Resuming..." when a client disconnects. + // We skip clearing during assembly reloads since OnBeforeAssemblyReload sets the flag + // and expects it to still be set after the domain reload. + // Check BOTH the explicit reload flag and isCompiling to handle timing edge cases. + bool isReloading = _isReloading || EditorApplication.isCompiling; + if (!isReloading) + { + try { EditorPrefs.DeleteKey(EditorPrefKeys.ResumeStdioAfterReload); } catch { } + } + TcpClient[] toClose; lock (clientsLock) { @@ -461,24 +518,30 @@ public static void Stop() try { EditorApplication.update -= ProcessCommands; } catch { } try { EditorApplication.quitting -= Stop; } catch { } - try + // Delete status file only if NOT reloading + // During domain reload, the status file must remain so Python server can detect + // the reloading state and return "retry" hint to clients + if (!isReloading) { - string dir = Environment.GetEnvironmentVariable("UNITY_MCP_STATUS_DIR"); - if (string.IsNullOrWhiteSpace(dir)) + try { - dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unity-mcp"); + string dir = Environment.GetEnvironmentVariable("UNITY_MCP_STATUS_DIR"); + if (string.IsNullOrWhiteSpace(dir)) + { + dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unity-mcp"); + } + string statusFile = Path.Combine(dir, $"unity-mcp-status-{ComputeProjectHash(Application.dataPath)}.json"); + if (File.Exists(statusFile)) + { + File.Delete(statusFile); + if (IsDebugEnabled()) McpLog.Info($"Deleted status file: {statusFile}"); + } } - string statusFile = Path.Combine(dir, $"unity-mcp-status-{ComputeProjectHash(Application.dataPath)}.json"); - if (File.Exists(statusFile)) + catch (Exception ex) { - File.Delete(statusFile); - if (IsDebugEnabled()) McpLog.Info($"Deleted status file: {statusFile}"); + if (IsDebugEnabled()) McpLog.Warn($"Failed to delete status file: {ex.Message}"); } } - catch (Exception ex) - { - if (IsDebugEnabled()) McpLog.Warn($"Failed to delete status file: {ex.Message}"); - } if (IsDebugEnabled()) McpLog.Info("StdioBridgeHost stopped."); } diff --git a/MCPForUnity/Editor/Tools/ActionTraceSettingsTool.cs b/MCPForUnity/Editor/Tools/ActionTraceSettingsTool.cs new file mode 100644 index 000000000..3d36b45e5 --- /dev/null +++ b/MCPForUnity/Editor/Tools/ActionTraceSettingsTool.cs @@ -0,0 +1,64 @@ +using System; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.ActionTrace.Core; +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Tools +{ + /// + /// MCP tool for querying ActionTrace system settings. + /// + /// Returns the current configuration of the ActionTrace system, + /// allowing Python端 to access live settings instead of hardcoded defaults. + /// + [McpForUnityTool("get_action_trace_settings")] + public static class ActionTraceSettingsTool + { + /// + /// Parameters for get_action_trace_settings tool. + /// This tool takes no parameters. + /// + public class Parameters + { + // No parameters required + } + + public static object HandleCommand(JObject @params) + { + try + { + var settings = ActionTraceSettings.Instance; + + return new SuccessResponse("Retrieved ActionTrace settings.", new + { + schema_version = "action_trace_settings@1", + + // Event filtering + min_importance_for_recording = settings.Filtering.MinImportanceForRecording, + disabled_event_types = settings.Filtering.DisabledEventTypes, + + // Event merging + enable_event_merging = settings.Merging.EnableEventMerging, + merge_window_ms = settings.Merging.MergeWindowMs, + + // Storage limits + max_events = settings.Storage.MaxEvents, + hot_event_count = settings.Storage.HotEventCount, + + // Transaction aggregation + transaction_window_ms = settings.Merging.TransactionWindowMs, + + // Current store state + current_sequence = EventStore.CurrentSequence, + total_events_stored = EventStore.Count, + context_mapping_count = EventStore.ContextMappingCount + }); + } + catch (Exception ex) + { + McpLog.Error($"[ActionTraceSettingsTool] Error: {ex.Message}"); + return new ErrorResponse($"Failed to get ActionTrace settings: {ex.Message}"); + } + } + } +} diff --git a/MCPForUnity/Editor/Tools/AddTimelineNoteTool.cs b/MCPForUnity/Editor/Tools/AddTimelineNoteTool.cs new file mode 100644 index 000000000..1a4234880 --- /dev/null +++ b/MCPForUnity/Editor/Tools/AddTimelineNoteTool.cs @@ -0,0 +1,172 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json.Linq; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.ActionTrace.Core; + +namespace MCPForUnity.Editor.Tools +{ + /// + /// MCP Tool for adding AI comments/notes to the ActionTrace. + /// + /// Usage: AI agents call this tool to record summaries, decisions, or task completion notes. + /// + /// Multi-Agent Collaboration: + /// - task_id: Groups all notes from a single task (e.g., "refactor-player-movement") + /// - conversation_id: Tracks continuity across sessions + /// - agent_id: Identifies which AI wrote the note + /// + /// Example payload: + /// { + /// "note": "完成玩家移动系统的重构,速度从 5 提升到 8", + /// "agent_id": "ChatGLM 1337", + /// "intent": "refactoring", + /// "task_id": "task-abc123", + /// "conversation_id": "conv-xyz789", + /// "related_sequences": [100, 101, 102] + /// } + /// + [McpForUnityTool("add_action_trace_note", Description = "Adds AI notes/annotations to the ActionTrace for task tracking")] + public static class AddActionTraceNoteTool + { + /// + /// Parameters for add_action_trace_note tool. + /// + public class Parameters + { + /// + /// The note text to record + /// + [ToolParameter("The note text to record", Required = true)] + public string Note { get; set; } + + /// + /// Identifies which AI wrote the note (default: "unknown") + /// + [ToolParameter("Identifies which AI wrote the note", Required = false, DefaultValue = "unknown")] + public string AgentId { get; set; } = "unknown"; + + /// + /// Groups all notes from a single task + /// + [ToolParameter("Groups all notes from a single task (e.g., 'refactor-player-movement')", Required = false)] + public string TaskId { get; set; } + + /// + /// Tracks continuity across sessions + /// + [ToolParameter("Tracks continuity across sessions", Required = false)] + public string ConversationId { get; set; } + + /// + /// Intent or purpose of the note + /// + [ToolParameter("Intent or purpose of the note", Required = false)] + public string Intent { get; set; } + + /// + /// Model identifier of the AI agent + /// + [ToolParameter("Model identifier of the AI agent", Required = false)] + public string AgentModel { get; set; } + + /// + /// Related event sequences to link with this note + /// + [ToolParameter("Related event sequences to link with this note", Required = false)] + public long[] RelatedSequences { get; set; } + } + + public static object HandleCommand(JObject @params) + { + try + { + // Required parameters + string note = @params["note"]?.ToString(); + if (string.IsNullOrEmpty(note)) + { + return new ErrorResponse("Note text is required."); + } + + // Support both snake_case (legacy) and camelCase (normalized by batch_execute) + string agentId = @params["agent_id"]?.ToString() ?? @params["agentId"]?.ToString() ?? "unknown"; + string taskId = @params["task_id"]?.ToString() ?? @params["taskId"]?.ToString(); + string conversationId = @params["conversation_id"]?.ToString() ?? @params["conversationId"]?.ToString(); + + // Build payload with all fields + var payload = new Dictionary + { + ["note"] = note, + ["agent_id"] = agentId + }; + + // Task-level tracking (P1.2 multi-agent collaboration) + if (!string.IsNullOrEmpty(taskId)) + { + payload["task_id"] = taskId; + } + + // Conversation-level tracking (cross-session continuity) + if (!string.IsNullOrEmpty(conversationId)) + { + payload["conversation_id"] = conversationId; + } + + // Optional fields - support both snake_case and camelCase + var intentToken = @params["intent"] ?? @params["Intent"]; + if (intentToken != null) + { + payload["intent"] = intentToken.ToString(); + } + + var agentModelToken = @params["agent_model"] ?? @params["agentModel"]; + if (agentModelToken != null) + { + payload["agent_model"] = agentModelToken.ToString(); + } + + // Related event sequences (if explicitly linking to specific events) + var relatedSeqToken = @params["related_sequences"] ?? @params["relatedSequences"]; + if (relatedSeqToken != null) + { + try + { + var relatedSeqs = relatedSeqToken.ToObject(); + if (relatedSeqs != null && relatedSeqs.Length > 0) + { + payload["related_sequences"] = relatedSeqs; + } + } + catch (Exception ex) + { + McpLog.Warn($"[AddActionTraceNoteTool] Failed to parse related_sequences: {ex.Message}"); + } + } + + // Record the AINote event + var evt = new EditorEvent( + sequence: 0, // Assigned by EventStore.Record() + timestampUnixMs: DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + type: "AINote", // P1.2: AI notes are always critical importance + targetId: $"agent:{agentId}", + payload: payload + ); + + long recordedSequence = EventStore.Record(evt); + + return new SuccessResponse($"AI note added to action trace (sequence {recordedSequence})", new + { + sequence = recordedSequence, + timestamp_unix_ms = evt.TimestampUnixMs, + task_id = taskId, + conversation_id = conversationId + }); + } + catch (Exception ex) + { + McpLog.Error($"[AddActionTraceNoteTool] Error: {ex.Message}"); + return new ErrorResponse($"Failed to add action trace note: {ex.Message}"); + } + } + } +} diff --git a/MCPForUnity/Editor/Tools/GetActionTraceTool.cs b/MCPForUnity/Editor/Tools/GetActionTraceTool.cs new file mode 100644 index 000000000..23ed20fa2 --- /dev/null +++ b/MCPForUnity/Editor/Tools/GetActionTraceTool.cs @@ -0,0 +1,86 @@ +using System; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Resources.ActionTrace; +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Tools +{ + /// + /// MCP tool for querying the action trace of editor events. + /// + /// This is a convenience wrapper around ActionTraceViewResource that provides + /// a cleaner "get_action_trace" tool name for AI consumption. + /// + [McpForUnityTool("get_action_trace", Description = "Queries the ActionTrace for editor events with filtering options")] + public static class GetActionTraceTool + { + /// + /// Parameters for get_action_trace tool. + /// + public class Parameters + { + /// + /// Maximum number of events to return (1-1000, default: 50) + /// + [ToolParameter("Maximum number of events to return (1-1000, default: 50)", Required = false, DefaultValue = "50")] + public int Limit { get; set; } = 50; + + /// + /// Only return events after this sequence number (for incremental queries) + /// + [ToolParameter("Only return events after this sequence number", Required = false)] + public long? SinceSequence { get; set; } + + /// + /// Filter by event types (e.g., ["GameObjectCreated", "ComponentAdded"]) + /// + [ToolParameter("Filter by event types", Required = false)] + public string[] EventTypes { get; set; } + + /// + /// Whether to include full event payload (default: true) + /// + [ToolParameter("Whether to include full event payload", Required = false, DefaultValue = "true")] + public bool IncludePayload { get; set; } = true; + + /// + /// Whether to include context associations (default: false) + /// + [ToolParameter("Whether to include context associations", Required = false, DefaultValue = "false")] + public bool IncludeContext { get; set; } = false; + + /// + /// Whether to include semantic analysis results (importance, category, intent) + /// + [ToolParameter("Whether to include semantic analysis results", Required = false, DefaultValue = "false")] + public bool IncludeSemantics { get; set; } = false; + + /// + /// Minimum importance level (low/medium/high/critical) + /// + [ToolParameter("Minimum importance level", Required = false, DefaultValue = "medium")] + public string MinImportance { get; set; } = "medium"; + + /// + /// Filter by task ID + /// + [ToolParameter("Filter by task ID", Required = false)] + public string TaskId { get; set; } + + /// + /// Filter by conversation ID + /// + [ToolParameter("Filter by conversation ID", Required = false)] + public string ConversationId { get; set; } + } + + /// + /// Main handler for action trace queries. + /// + public static object HandleCommand(JObject @params) + { + // Delegate to the existing ActionTraceViewResource implementation + return ActionTraceViewResource.HandleCommand(@params); + } + } +} diff --git a/MCPForUnity/Editor/Tools/ManageAsset.cs b/MCPForUnity/Editor/Tools/ManageAsset.cs index a285c9dcb..f1b4e2558 100644 --- a/MCPForUnity/Editor/Tools/ManageAsset.cs +++ b/MCPForUnity/Editor/Tools/ManageAsset.cs @@ -25,7 +25,31 @@ namespace MCPForUnity.Editor.Tools [McpForUnityTool("manage_asset", AutoRegister = false)] public static class ManageAsset { - // --- Main Handler --- + // ======================================================================== + // ActionTrace Integration (Low-Coupling Event Callbacks) + // ======================================================================== + /// + /// Callback raised when an asset is modified. External systems (like ActionTrace) + /// can subscribe to this to track changes without tight coupling. + /// + /// Parameters: (assetPath, assetType, changesDictionary) + /// - changesDictionary: property path -> {old, new} values + /// + public static event Action> OnAssetModified; + + /// + /// Callback raised when an asset is created. + /// + public static event Action OnAssetCreated; + + /// + /// Callback raised when an asset is deleted. + /// + public static event Action OnAssetDeleted; + + // ======================================================================== + // Main Handler + // ======================================================================== // Define the list of valid actions private static readonly List ValidActions = new List @@ -264,6 +288,10 @@ private static object CreateAsset(JObject @params) } AssetDatabase.SaveAssets(); + + // === ActionTrace Integration: Notify subscribers (low-coupling) === + OnAssetCreated?.Invoke(fullPath, assetType); + // AssetDatabase.Refresh(); // CreateAsset often handles refresh return new SuccessResponse( $"Asset '{fullPath}' created successfully.", @@ -466,6 +494,14 @@ prop.Value is JObject componentProperties EditorUtility.SetDirty(asset); // Save all modified assets to disk. AssetDatabase.SaveAssets(); + + // === ActionTrace Integration: Notify subscribers (low-coupling) === + OnAssetModified?.Invoke( + fullPath, + asset.GetType().FullName, + properties.ToObject>() + ); + // Refresh might be needed in some edge cases, but SaveAssets usually covers it. // AssetDatabase.Refresh(); return new SuccessResponse( @@ -500,11 +536,17 @@ private static object DeleteAsset(string path) if (!AssetExists(fullPath)) return new ErrorResponse($"Asset not found at path: {fullPath}"); + // Capture asset type before deletion (for ActionTrace callback) + string assetType = AssetDatabase.GetMainAssetTypeAtPath(fullPath)?.FullName ?? "Unknown"; + try { bool success = AssetDatabase.DeleteAsset(fullPath); if (success) { + // === ActionTrace Integration: Notify subscribers (low-coupling) === + OnAssetDeleted?.Invoke(fullPath, assetType); + // AssetDatabase.Refresh(); // DeleteAsset usually handles refresh return new SuccessResponse($"Asset '{fullPath}' deleted successfully."); } diff --git a/MCPForUnity/Editor/Tools/UndoToSequenceTool.cs b/MCPForUnity/Editor/Tools/UndoToSequenceTool.cs new file mode 100644 index 000000000..b910d7761 --- /dev/null +++ b/MCPForUnity/Editor/Tools/UndoToSequenceTool.cs @@ -0,0 +1,159 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json.Linq; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.ActionTrace.Core; +using UnityEditor; + +namespace MCPForUnity.Editor.Tools +{ + /// + /// MCP Tool for reverting the editor state to a specific ActionTrace sequence. + /// + /// Purpose (from ActionTrace-enhancements.md P2.4): + /// - Allows AI to "undo" to a previous state identified by sequence number + /// - Provides "regret medicine" for AI operations + /// + /// Implementation Notes: + /// - Unity Undo API does NOT support "revert to specific group" directly + /// - This tool calculates how many Undo steps are needed to reach target sequence + /// - Performs multiple Undo.PerformUndo() operations + /// + /// Limitations: + /// - Only works if Undo history is intact (no domain reload) + /// - Cannot redo after this operation (standard Ctrl+Y won't work) + /// - Best used immediately after realizing a mistake + /// + [McpForUnityTool("undo_to_sequence")] + public static class UndoToSequenceTool + { + /// + /// Parameters for undo_to_sequence tool. + /// + public class Parameters + { + /// + /// Target sequence number to revert to + /// + [ToolParameter("Target sequence number to revert to", Required = true)] + public long SequenceId { get; set; } + + /// + /// If true, only calculate steps without executing + /// + [ToolParameter("If true, only calculate steps without executing", Required = false, DefaultValue = "false")] + public bool DryRun { get; set; } = false; + } + + /// + /// Handles the undo_to_sequence command. + /// + /// Parameters: + /// sequence_id (long): Target sequence number to revert to + /// dry_run (bool, optional): If true, only calculate steps without executing + /// + /// Returns: + /// Success response with steps performed and information + /// + public static object HandleCommand(JObject @params) + { + try + { + // Parse required parameter + long targetSequence = 0; + var sequenceToken = @params["sequence_id"] ?? @params["sequenceId"]; + if (sequenceToken == null || !long.TryParse(sequenceToken.ToString(), out targetSequence)) + { + return new ErrorResponse("sequence_id parameter is required and must be a number."); + } + + // Optional dry_run parameter + bool dryRun = false; + var dryRunToken = @params["dry_run"] ?? @params["dryRun"]; + if (dryRunToken != null) + { + bool.TryParse(dryRunToken.ToString(), out dryRun); + } + + // Get current sequence + long currentSequence = EventStore.CurrentSequence; + if (targetSequence >= currentSequence) + { + return new ErrorResponse($"Target sequence ({targetSequence}) is not in the past. Current sequence: {currentSequence}"); + } + + // Query events to find which ones occurred after target sequence + var eventsAfterTarget = EventStore.Query( + limit: int.MaxValue, // Get all events + sinceSequence: targetSequence + ).ToList(); + + if (eventsAfterTarget.Count == 0) + { + return new ErrorResponse($"No events found after sequence {targetSequence}."); + } + + // Count Undo groups after target sequence + var undoGroupsAfterTarget = new HashSet(); + foreach (var evt in eventsAfterTarget) + { + if (evt.Payload != null && evt.Payload.TryGetValue("undo_group", out var groupObj)) + { + if (int.TryParse(groupObj?.ToString(), out int groupId)) + { + undoGroupsAfterTarget.Add(groupId); + } + } + } + + int stepsToUndo = undoGroupsAfterTarget.Count; + + if (stepsToUndo == 0) + { + return new SuccessResponse($"No Undo operations to perform. The target sequence {targetSequence} may have been reached without Undo-recorded operations."); + } + + // Dry run - only return information + if (dryRun) + { + return new SuccessResponse($"Dry run: {stepsToUndo} Undo steps needed to reach sequence {targetSequence}", new + { + target_sequence = targetSequence, + current_sequence = currentSequence, + undo_steps_needed = stepsToUndo, + events_after_target = eventsAfterTarget.Count, + affected_undo_groups = undoGroupsAfterTarget.ToArray() + }); + } + + // Perform the undo operations + McpLog.Info($"[UndoToSequenceTool] Reverting {stepsToUndo} Undo steps to reach sequence {targetSequence}"); + + for (int i = 0; i < stepsToUndo; i++) + { + Undo.PerformUndo(); + } + + // Force GUI refresh to update the scene + EditorApplication.delayCall += () => + { + UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty(UnityEditor.SceneManagement.EditorSceneManager.GetActiveScene()); + }; + + return new SuccessResponse($"Successfully reverted {stepsToUndo} Undo steps to reach sequence {targetSequence}", new + { + target_sequence = targetSequence, + undo_steps_performed = stepsToUndo, + events_after_target = eventsAfterTarget.Count, + note = "Warning: Redo (Ctrl+Y) may not work correctly after this operation. Consider this a one-way revert." + }); + } + catch (Exception ex) + { + McpLog.Error($"[UndoToSequenceTool] Error: {ex.Message}"); + return new ErrorResponse($"Failed to undo to sequence: {ex.Message}"); + } + } + } +} diff --git a/MCPForUnity/Editor/Tools/UndoToSequenceTool.cs.meta b/MCPForUnity/Editor/Tools/UndoToSequenceTool.cs.meta new file mode 100644 index 000000000..937869870 --- /dev/null +++ b/MCPForUnity/Editor/Tools/UndoToSequenceTool.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7fe08e9f7251a0e4fb69cd385c0e8f2c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Windows/ActionTraceEditorWindow.cs b/MCPForUnity/Editor/Windows/ActionTraceEditorWindow.cs new file mode 100644 index 000000000..a670f76f7 --- /dev/null +++ b/MCPForUnity/Editor/Windows/ActionTraceEditorWindow.cs @@ -0,0 +1,433 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.ActionTrace.Core; +using MCPForUnity.Editor.ActionTrace.Query; +using UnityEditor; +using UnityEditor.UIElements; +using UnityEngine; +using UnityEngine.UIElements; + +namespace MCPForUnity.Editor.Windows +{ + /// + /// 排序模式:控制事件列表的排序方式 + /// + public enum SortMode + { + /// 纯时间排序(最新优先)- 给用户查看记录 + ByTimeDesc, + /// AI 视角排序 - 先按时间再按重要性分组 + AIFiltered + } + + public sealed class ActionTraceEditorWindow : EditorWindow + { + #region Constants + + private const string UxmlName = "ActionTraceEditorWindow"; + private const double RefreshInterval = 1.0; + private const int DefaultQueryLimit = 200; + + // UI Element Names + private static class UINames + { + public const string EventList = "event-list"; + public const string DetailScrollView = "detail-scroll-view"; + public const string StatusLabel = "status-label"; + public const string CountLabel = "count-label"; + public const string SearchField = "search-field"; + public const string ImportanceToggle = "importance-toggle"; + public const string ContextToggle = "context-toggle"; + public const string FilterMenu = "filter-menu"; + public const string SortMenu = "sort-menu"; + public const string SettingsButton = "settings-button"; + public const string RefreshButton = "refresh-button"; + public const string ClearButton = "clear-button"; + } + + // USS Class Names + private static class Classes + { + public const string EventItem = "event-item"; + public const string EventTime = "event-time"; + public const string EventType = "event-type"; + public const string EventSummary = "event-summary"; + public const string ImportanceBadge = "importance-badge"; + public const string HasContext = "has-context"; + public const string DetailContainer = "detail-container"; + public const string DetailRow = "detail-row"; + public const string DetailLabel = "detail-label"; + public const string DetailValue = "detail-value"; + } + + #endregion + + // UI + private ListView _eventListView; + private ScrollView _detailScrollView; + private Label _statusLabel; + private Label _countLabel; + private ToolbarSearchField _searchField; + private ToolbarToggle _importanceToggle; + private ToolbarToggle _contextToggle; + private ToolbarMenu _filterMenu; + private ToolbarMenu _sortMenu; + private ToolbarButton _settingsButton; + private ToolbarButton _refreshButton; + private ToolbarButton _clearButton; + + // Data + private readonly List _currentEvents = new(); + private ActionTraceQuery _actionTraceQuery; + + private string _searchText = string.Empty; + private float _minImportance; + private bool _showSemantics; + private bool _showContext; + private SortMode _sortMode = SortMode.ByTimeDesc; + + private double _lastRefreshTime; + + public static void ShowWindow() + { + var window = GetWindow("ActionTrace"); + window.minSize = new Vector2(900, 600); + } + + private void CreateGUI() + { + var uxml = LoadUxmlAsset(); + if (uxml == null) return; + + uxml.CloneTree(rootVisualElement); + if (rootVisualElement.childCount == 0) + { + McpLog.Error("ActionTraceEditorWindow: UXML loaded but rootVisualElement is empty."); + return; + } + + SetupReferences(); + ValidateRequiredElements(); + SetupListView(); + SetupToolbar(); + + _actionTraceQuery = new ActionTraceQuery(); + _minImportance = ActionTraceSettings.Instance?.Filtering.MinImportanceForRecording ?? 0.4f; + + // Always record all events, filter at query time based on mode + if (ActionTraceSettings.Instance != null) + ActionTraceSettings.Instance.Filtering.BypassImportanceFilter = true; + + RefreshEvents(); + UpdateStatus(); + } + + private VisualTreeAsset LoadUxmlAsset() + { + // Try loading by name first (simplest approach) + var guids = AssetDatabase.FindAssets($"{UxmlName} t:VisualTreeAsset"); + if (guids?.Length > 0) + { + var path = AssetDatabase.GUIDToAssetPath(guids[0]); + var asset = AssetDatabase.LoadAssetAtPath(path); + if (asset != null) return asset; + } + + // Fallback: try package-relative path + var basePath = AssetPathUtility.GetMcpPackageRootPath(); + if (!string.IsNullOrEmpty(basePath)) + { + var expectedPath = $"{basePath}/Editor/Windows/{UxmlName}.uxml"; + var sanitized = AssetPathUtility.SanitizeAssetPath(expectedPath); + var asset = AssetDatabase.LoadAssetAtPath(sanitized); + if (asset != null) return asset; + } + + McpLog.Error($"ActionTraceEditorWindow.uxml not found in project."); + return null; + } + + private void ValidateRequiredElements() + { + if (_eventListView == null) + McpLog.Error($"'{UINames.EventList}' ListView not found in UXML."); + if (_detailScrollView == null) + McpLog.Error($"'{UINames.DetailScrollView}' ScrollView not found in UXML."); + if (_statusLabel == null) + McpLog.Error($"'{UINames.StatusLabel}' Label not found in UXML."); + if (_countLabel == null) + McpLog.Error($"'{UINames.CountLabel}' Label not found in UXML."); + } + + private void SetupReferences() + { + _eventListView = rootVisualElement.Q(UINames.EventList); + _detailScrollView = rootVisualElement.Q(UINames.DetailScrollView); + _statusLabel = rootVisualElement.Q