diff --git a/MCPForUnity/Editor/ActionTrace/Analysis/Query/ActionTraceQuery.cs b/MCPForUnity/Editor/ActionTrace/Analysis/Query/ActionTraceQuery.cs new file mode 100644 index 000000000..a5fcf5481 --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Analysis/Query/ActionTraceQuery.cs @@ -0,0 +1,374 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MCPForUnity.Editor.ActionTrace.Context; +using MCPForUnity.Editor.ActionTrace.Core; +using MCPForUnity.Editor.ActionTrace.Core.Models; +using MCPForUnity.Editor.ActionTrace.Semantics; +using MCPForUnity.Editor.Helpers; +using UnityEngine; + +namespace MCPForUnity.Editor.ActionTrace.Analysis.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 Semantics.DefaultEventScorer(); + _categorizer = categorizer ?? new Semantics.DefaultCategorizer(); + _inferrer = inferrer ?? new Semantics.DefaultIntentInferrer(); + } + + /// + /// Project events with computed semantic information. + /// Returns ActionTraceViewItem objects containing the original event plus + /// dynamically calculated importance, category, and intent. + /// Also converts GlobalID to InstanceID and Name for unified display. + /// + public IReadOnlyList Project(IReadOnlyList events) + { + if (events == null || events.Count == 0) + return Array.Empty(); + + var result = new ActionTraceViewItem[events.Count]; + + // Build instance info cache for performance (many events may reference the same object) + var instanceInfoCache = new Dictionary(); + + 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); + + // Convert GlobalID to InstanceID and Name (with caching) + (int? instanceId, string displayName) targetInfo = (null, null); + if (!string.IsNullOrEmpty(evt.TargetId)) + { + if (!instanceInfoCache.TryGetValue(evt.TargetId, out targetInfo)) + { + targetInfo = GlobalIdHelper.GetInstanceInfo(evt.TargetId); + instanceInfoCache[evt.TargetId] = targetInfo; + } + } + + 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, + // Target info (converted from GlobalID) + TargetInstanceId = targetInfo.instanceId, + TargetName = targetInfo.displayName + }; + } + + return result; + } + + /// + /// Project events with context associations. + /// Overload for QueryWithContext results. + /// Also converts GlobalID to InstanceID and Name for unified display. + /// + public IReadOnlyList ProjectWithContext( + IReadOnlyList<(EditorEvent Event, ContextMapping Context)> eventsWithContext) + { + if (eventsWithContext == null || eventsWithContext.Count == 0) + return Array.Empty(); + + var result = new ActionTraceViewItem[eventsWithContext.Count]; + + // Build instance info cache for performance (many events may reference the same object) + var instanceInfoCache = new Dictionary(); + + 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); + + // Convert GlobalID to InstanceID and Name (with caching) + (int? instanceId, string displayName) targetInfo = (null, null); + if (!string.IsNullOrEmpty(evt.TargetId)) + { + if (!instanceInfoCache.TryGetValue(evt.TargetId, out targetInfo)) + { + targetInfo = GlobalIdHelper.GetInstanceInfo(evt.TargetId); + instanceInfoCache[evt.TargetId] = targetInfo; + } + } + + 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, + // Target info (converted from GlobalID) + TargetInstanceId = targetInfo.instanceId, + TargetName = targetInfo.displayName + }; + } + + 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 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; } + + /// + /// Unity InstanceID converted from GlobalID. + /// Null if object no longer exists or GlobalID is invalid. + /// Used for runtime object access and tool integration. + /// + public int? TargetInstanceId { get; set; } + + /// + /// Human-readable target name converted from GlobalID. + /// GameObject name if resolvable, or formatted ID/placeholder if not found. + /// + public string TargetName { 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/Analysis/Summarization/EventSummarizer.cs b/MCPForUnity/Editor/ActionTrace/Analysis/Summarization/EventSummarizer.cs new file mode 100644 index 000000000..4e094f692 --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Analysis/Summarization/EventSummarizer.cs @@ -0,0 +1,498 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.RegularExpressions; +using MCPForUnity.Editor.ActionTrace.Core; +using MCPForUnity.Editor.ActionTrace.Core.Models; + +namespace MCPForUnity.Editor.ActionTrace.Analysis.Summarization +{ + /// + /// 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 using StringBuilder.Replace + // This avoids potential infinite loops when a value contains its own placeholder + foreach (var kvp in evt.Payload ?? new Dictionary()) + { + string placeholder = "{" + kvp.Key + "}"; + string value = FormatValue(kvp.Value); + sb.Replace(placeholder, value); + } + + // Special placeholders + sb.Replace("{type}", evt.Type ?? ""); + sb.Replace("{target}", GetTargetName(evt) ?? ""); + // Use InstanceID instead of GlobalID for {target_id} placeholder + var instanceInfo = GlobalIdHelper.GetInstanceInfo(evt.TargetId); + sb.Replace("{target_id}", instanceInfo.instanceId.HasValue + ? instanceInfo.instanceId.Value.ToString() + : (instanceInfo.displayName ?? 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 defaultValue if key doesn't exist. + /// + private static string GetPayloadStringValue(EditorEvent evt, string key, string defaultValue = "") + { + if (evt.Payload != null && evt.Payload.TryGetValue(key, out var value)) + { + return value?.ToString() ?? defaultValue; + } + return defaultValue; + } + + /// + /// 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, false), + EventTypes.SelectionPropertyModified => SummarizePropertyModified(evt, true), + _ => null + }; + } + + /// + /// Generate a human-readable summary for property modification events. + /// Format: "Changed {ComponentType}.{PropertyPath} from {StartValue} to {EndValue} (GameObject:{instance_id})" + /// Strips "m_" prefix from Unity serialized property names. + /// Uses InstanceID for tool invocation instead of GlobalID. + /// + private static string SummarizePropertyModified(EditorEvent evt, bool isSelection) + { + if (evt.Payload == null) + return isSelection ? "Property modified (selected)" : "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)) + { + baseSummary = !string.IsNullOrEmpty(startValue) && !string.IsNullOrEmpty(endValue) + ? $"Changed {componentType}.{readableProperty} from {startValue} to {endValue}" + : $"Changed {componentType}.{readableProperty}"; + } + else if (!string.IsNullOrEmpty(targetName)) + { + baseSummary = !string.IsNullOrEmpty(readableProperty) + ? $"Changed {targetName}.{readableProperty}" + : $"Changed {targetName}"; + } + else + { + return isSelection ? "Property modified (selected)" : "Property modified"; + } + + // Append GameObject InstanceID (instead of GlobalID) for tool invocation + // Get InstanceID from GlobalID - if object not found, show formatted placeholder + var instanceInfo = GlobalIdHelper.GetInstanceInfo(evt.TargetId); + string instanceDisplay = instanceInfo.instanceId.HasValue + ? instanceInfo.instanceId.Value.ToString() + : $"[{instanceInfo.displayName}]"; + + if (string.IsNullOrEmpty(evt.TargetId)) + return baseSummary + (isSelection ? " (selected)" : ""); + + return isSelection + ? $"{baseSummary} (selected, GameObject:{instanceDisplay})" + : $"{baseSummary} (GameObject:{instanceDisplay})"; + } + + /// + /// 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 defaultValue if key doesn't exist. + /// + private static string GetPayloadString(EditorEvent evt, string key, string defaultValue = null) + { + if (evt.Payload != null && evt.Payload.TryGetValue(key, out var value)) + { + return value?.ToString(); + } + return defaultValue; + } + + /// + /// 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 resolving GlobalID to get the object name. + /// + private static string GetTargetName(EditorEvent evt) + { + // Try to get a human-readable name from payload first + if (evt.Payload != null) + { + if (evt.Payload.TryGetValue("name", out var name) && name != null) + return name.ToString(); + if (evt.Payload.TryGetValue("game_object", out var goName) && goName != null) + return goName.ToString(); + if (evt.Payload.TryGetValue("scene_name", out var sceneName) && sceneName != null) + return sceneName.ToString(); + if (evt.Payload.TryGetValue("component_type", out var compType) && compType != null) + return compType.ToString(); + if (evt.Payload.TryGetValue("path", out var path) && path != null) + return path.ToString(); + } + + // Fall back to resolving GlobalID to get display name + // This will return the object name if resolvable, or a formatted ID/placeholder + if (!string.IsNullOrEmpty(evt.TargetId)) + return GlobalIdHelper.GetDisplayName(evt.TargetId); + + return ""; + } + + /// + /// 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/Analysis/Summarization/TransactionAggregator.cs b/MCPForUnity/Editor/ActionTrace/Analysis/Summarization/TransactionAggregator.cs new file mode 100644 index 000000000..9ec96d377 --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Analysis/Summarization/TransactionAggregator.cs @@ -0,0 +1,263 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MCPForUnity.Editor.ActionTrace.Core.Models; +using MCPForUnity.Editor.ActionTrace.Core.Settings; +using MCPForUnity.Editor.ActionTrace.Helpers; + +namespace MCPForUnity.Editor.ActionTrace.Analysis.Summarization +{ + /// + /// 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 tool_call_id from event Payload. + /// Returns null if not present. + /// + private static string GetToolCallId(EditorEvent evt) => evt.GetPayloadString("tool_call_id"); + + /// + /// Extracts triggered_by_tool from event Payload. + /// Returns null if not present. + /// + private static string GetTriggeredByTool(EditorEvent evt) => evt.GetPayloadString("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/Capture/Capturers/AssetCapture.cs b/MCPForUnity/Editor/ActionTrace/Capture/Capturers/AssetCapture.cs new file mode 100644 index 000000000..94700597b --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Capture/Capturers/AssetCapture.cs @@ -0,0 +1,382 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MCPForUnity.Editor.ActionTrace.Core; +using MCPForUnity.Editor.ActionTrace.Core.Models; +using MCPForUnity.Editor.ActionTrace.Core.Store; +using MCPForUnity.Editor.ActionTrace.Integration.VCS; +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 + { + /// + /// Tracks assets processed in the current session to prevent duplicate events. + /// Unity's OnPostprocessAllAssets can fire multiple times for the same asset + /// during different phases (creation, compilation, re-import). + /// + /// IMPORTANT: This uses a persistent file cache because Domain Reload + /// (script compilation) resets all static fields, causing in-memory + /// tracking to lose its state. + /// + private static HashSet _processedAssetsInSession + { + get + { + if (_cachedProcessedAssets == null) + LoadProcessedAssets(); + return _cachedProcessedAssets; + } + } + + private static HashSet _cachedProcessedAssets; + private const string CacheFileName = "AssetChangePostprocessor.cache"; + + /// + /// Loads the processed assets cache from disk. + /// Called lazily when _processedAssetsInSession is first accessed. + /// + private static void LoadProcessedAssets() + { + _cachedProcessedAssets = new HashSet(); + + try + { + string cachePath = GetCacheFilePath(); + if (!System.IO.File.Exists(cachePath)) + return; + + string json = System.IO.File.ReadAllText(cachePath); + var loaded = Newtonsoft.Json.JsonConvert.DeserializeObject(json); + if (loaded != null) + { + foreach (var path in loaded) + _cachedProcessedAssets.Add(path); + } + } + catch (Exception ex) + { + McpLog.Warn($"[AssetChangePostprocessor] Failed to load cache: {ex.Message}"); + _cachedProcessedAssets = new HashSet(); + } + } + + /// + /// Saves the current processed assets to disk. + /// Should be called after processing a batch of assets. + /// + private static void SaveProcessedAssets() + { + if (_cachedProcessedAssets == null) + return; + + try + { + string cachePath = GetCacheFilePath(); + + // If cache is empty, delete the cache file to persist the cleared state + if (_cachedProcessedAssets.Count == 0) + { + if (System.IO.File.Exists(cachePath)) + System.IO.File.Delete(cachePath); + return; + } + + string json = Newtonsoft.Json.JsonConvert.SerializeObject(_cachedProcessedAssets.ToArray()); + var dir = System.IO.Path.GetDirectoryName(cachePath); + if (!string.IsNullOrEmpty(dir) && !System.IO.Directory.Exists(dir)) + System.IO.Directory.CreateDirectory(dir); + System.IO.File.WriteAllText(cachePath, json); + } + catch (Exception ex) + { + McpLog.Warn($"[AssetChangePostprocessor] Failed to save cache: {ex.Message}"); + } + } + + /// + /// Gets the cache file path in the Library folder. + /// + private static string GetCacheFilePath() + { + return System.IO.Path.Combine( + UnityEngine.Application.dataPath.Substring(0, UnityEngine.Application.dataPath.Length - "Assets".Length), + "Library", + CacheFileName + ); + } + + private static void OnPostprocessAllAssets( + string[] importedAssets, + string[] deletedAssets, + string[] movedAssets, + string[] movedFromAssetPaths) + { + bool hasChanges = false; + + // Cleanup: Periodically clear old entries to prevent unbounded growth + // Use time-based expiration (30 minutes) instead of count-based + CleanupOldEntries(); + + // ========== Imported Assets (includes newly created assets) ========== + // Single-pass event classification: each asset produces exactly one event + // Priority: AssetCreated > AssetModified > AssetImported (mutually exclusive) + foreach (var assetPath in importedAssets) + { + if (string.IsNullOrEmpty(assetPath)) continue; + + // L0 Deduplication: Skip if already processed in this session + // This prevents duplicate events when Unity fires OnPostprocessAllAssets + // multiple times for the same asset (creation, compilation, re-import) + if (!_processedAssetsInSession.Add(assetPath)) + continue; // Already processed, skip to prevent duplicate events + + hasChanges = true; // Mark that we added a new entry + + // L1 Blacklist: Skip junk assets before creating events + if (!EventFilter.ShouldTrackAsset(assetPath)) + { + // Remove from tracking if it's a junk asset (we don't want to track it) + _processedAssetsInSession.Remove(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 + }; + + // Mutually exclusive event classification (prevents duplicate events) + if (IsNewlyCreatedAsset(assetPath)) + { + // Priority 1: Newly created assets (first-time existence) + RecordEvent(EventTypes.AssetCreated, targetId, payload); + } + else if (ShouldTrackModification(assetPath)) + { + // Priority 2: Existing assets with trackable modification types + // Covers: re-imports, content changes, settings updates + RecordEvent(EventTypes.AssetModified, targetId, payload); + } + else + { + // Priority 3: Generic imports (fallback for untracked types) + RecordEvent(EventTypes.AssetImported, targetId, payload); + } + } + + // ========== Deleted Assets ========== + foreach (var assetPath in deletedAssets) + { + if (string.IsNullOrEmpty(assetPath)) continue; + + // L0 Deduplication: Skip if already processed in this session + if (!_processedAssetsInSession.Add(assetPath)) + continue; + + hasChanges = true; // Mark that we added a new entry + + // 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; + + // L0 Deduplication: Skip if already processed in this session + if (!_processedAssetsInSession.Add(movedAssets[i])) + continue; + + hasChanges = true; // Mark that we added a new entry + + 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); + } + + // Persist the cache to disk if there were any changes + if (hasChanges) + SaveProcessedAssets(); + } + + /// + /// Cleanup old entries from the cache to prevent unbounded growth. + /// Uses time-based expiration (30 minutes) instead of count-based. + /// This is called at the start of each OnPostprocessAllAssets batch. + /// + private static void CleanupOldEntries() + { + if (_cachedProcessedAssets == null || _cachedProcessedAssets.Count == 0) + return; + + // Only cleanup periodically to avoid overhead + // Use a simple counter or timestamp-based approach + const int MaxCacheSize = 1000; + if (_cachedProcessedAssets.Count <= MaxCacheSize) + return; + + // If cache grows too large, clear it + // This is safe because re-processing old assets is extremely rare + _cachedProcessedAssets.Clear(); + SaveProcessedAssets(); + } + + /// + /// Determines if an asset was newly created vs imported. + /// + /// Heuristic: Checks the .meta file creation time. A very recent creation time + /// (within 5 seconds) indicates a newly created asset. Older .meta files indicate + /// re-imports of existing assets. + /// + /// This is a pragmatic approach since Unity's OnPostprocessAllAssets doesn't + /// distinguish between new creations and re-imports directly. + /// + private static bool IsNewlyCreatedAsset(string assetPath) + { + try + { + string metaPath = assetPath + ".meta"; + string fullPath = System.IO.Path.Combine(UnityEngine.Application.dataPath.Substring(0, UnityEngine.Application.dataPath.Length - "Assets".Length), metaPath); + + if (!System.IO.File.Exists(fullPath)) + return false; + + var creationTime = System.IO.File.GetCreationTimeUtc(fullPath); + var currentTime = DateTime.UtcNow; + var timeDiff = currentTime - creationTime; + + // If .meta file was created within 5 seconds, treat as newly created + // This threshold accounts for Unity's internal processing delays + return timeDiff.TotalSeconds <= 5.0; + } + catch + { + // On any error, default to treating as imported (conservative) + 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 = VcsContextProvider.GetCurrentContext(); + if (vcsContext != null) + { + payload["vcs_context"] = vcsContext.ToDictionary(); + } + + 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 += () => EventStore.Record(evt); + } + catch (Exception ex) + { + McpLog.Warn($"[AssetChangePostprocessor] Failed to record event: {ex.Message}"); + } + } + } +} diff --git a/MCPForUnity/Editor/ActionTrace/Capture/Capturers/PropertyCapture.cs b/MCPForUnity/Editor/ActionTrace/Capture/Capturers/PropertyCapture.cs new file mode 100644 index 000000000..8fc763f15 --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Capture/Capturers/PropertyCapture.cs @@ -0,0 +1,369 @@ +using System; +using System.Collections.Generic; +using UnityEditor; +using MCPForUnity.Editor.ActionTrace.Core; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.ActionTrace.Helpers; +using MCPForUnity.Editor.ActionTrace.Core.Models; +using MCPForUnity.Editor.ActionTrace.Core.Store; +using System.Threading; + +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 + /// - PropertyModificationHelper 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 object _lock = new(); + 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. + /// + /// Performance: Minimizes lock持有时间 by extracting data before locking. + /// + private static UndoPropertyModification[] ProcessModifications(UndoPropertyModification[] modifications) + { + if (modifications == null || modifications.Length == 0) + return modifications; + + // Phase 1: Extract data from Undo modifications (outside lock) + // This minimizes time spent inside the critical section + var extractedData = new List(modifications.Length); + long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + foreach (var undoMod in modifications) + { + var target = PropertyModificationHelper.GetTarget(undoMod); + if (target == null) + continue; + + var propertyPath = PropertyModificationHelper.GetPropertyPath(undoMod); + if (string.IsNullOrEmpty(propertyPath)) + continue; + + // Filter out Unity internal properties early + if (PropertyFormatter.IsInternalProperty(propertyPath)) + continue; + + string globalId = GlobalIdHelper.ToGlobalIdString(target); + if (string.IsNullOrEmpty(globalId)) + continue; + + var currentValue = PropertyModificationHelper.GetCurrentValue(undoMod); + var prevValue = PropertyModificationHelper.GetPreviousValue(undoMod); + + extractedData.Add(new ModificationData + { + GlobalId = globalId, + TargetName = target.name, + ComponentType = target.GetType().Name, + PropertyPath = propertyPath, + StartValue = PropertyFormatter.FormatPropertyValue(prevValue), + EndValue = PropertyFormatter.FormatPropertyValue(currentValue), + PropertyType = PropertyFormatter.GetPropertyTypeName(currentValue), + LastUpdateMs = nowMs + }); + } + + // Phase 2: Update pending changes (inside lock, minimal work) + lock (_lock) + { + foreach (var data in extractedData) + { + string uniqueKey = $"{data.GlobalId}:{data.PropertyPath}"; + + // Check if we already have a pending change for this property + if (_pendingChanges.TryGetValue(uniqueKey, out var pending)) + { + // Update existing pending change + pending.EndValue = data.EndValue; + pending.ChangeCount++; + pending.LastUpdateMs = data.LastUpdateMs; + _pendingChanges[uniqueKey] = pending; + } + else + { + // Enforce cache limit to prevent unbounded growth + if (_pendingChanges.Count >= MaxPendingEntries) + { + // Exit lock before flushing to avoid nested lock + Monitor.Exit(_lock); + try + { + FlushPendingChanges(force: true); + } + finally + { + Monitor.Enter(_lock); + } + } + + // Create new pending change (use object pool if available) + var change = AcquirePendingChange(); + change.GlobalId = data.GlobalId; + change.TargetName = data.TargetName; + change.ComponentType = data.ComponentType; + change.PropertyPath = data.PropertyPath; + change.StartValue = data.StartValue; + change.EndValue = data.EndValue; + change.PropertyType = data.PropertyType; + change.ChangeCount = 1; + change.LastUpdateMs = data.LastUpdateMs; + + _pendingChanges[uniqueKey] = change; + } + } + } + + return modifications; + } + + /// + /// Temporary structure to hold extracted modification data. + /// Used to minimize time spent inside lock. + /// + private struct ModificationData + { + public string GlobalId; + public string TargetName; + public string ComponentType; + public string PropertyPath; + public string StartValue; + public string EndValue; + public string PropertyType; + public long LastUpdateMs; + } + + /// + /// Flushes all pending property changes that have exceeded the debounce window. + /// Called periodically via EditorApplication.update. + /// + /// When force=true, bypasses the debounce age check and flushes ALL entries. + /// Used for shutdown or when cache limit is reached. + /// + private static void FlushPendingChanges(bool force = false) + { + lock (_lock) + { + if (_pendingChanges.Count == 0) + return; + + long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + foreach (var kvp in _pendingChanges) + { + // When forced, flush all entries. Otherwise, only flush expired entries. + if (force || 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, bypassing debounce window. + /// Useful for shutdown or before critical operations. + /// + public static void ForceFlush() + { + FlushPendingChanges(force: true); + } + + /// + /// Gets the current count of pending changes. + /// Useful for debugging and monitoring. + /// + public static int PendingCount + { + get + { + lock (_lock) + { + return _pendingChanges.Count; + } + } + } + + /// + /// Clears all pending changes without recording them. + /// Useful for testing or error recovery. + /// + public static void ClearPending() + { + lock (_lock) + { + 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/Capturers/SelectionCapture.cs b/MCPForUnity/Editor/ActionTrace/Capture/Capturers/SelectionCapture.cs new file mode 100644 index 000000000..72799d270 --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Capture/Capturers/SelectionCapture.cs @@ -0,0 +1,215 @@ +using System; +using System.Collections.Generic; +using UnityEditor; +using UnityEngine; +using MCPForUnity.Editor.ActionTrace.Core; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.ActionTrace.Helpers; +using MCPForUnity.Editor.ActionTrace.Core.Models; +using MCPForUnity.Editor.ActionTrace.Core.Store; + +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 = PropertyModificationHelper.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 = PropertyModificationHelper.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 = PropertyModificationHelper.GetCurrentValue(undoMod); + var prevValue = PropertyModificationHelper.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/Emitter.cs b/MCPForUnity/Editor/ActionTrace/Capture/Emitter.cs new file mode 100644 index 000000000..af301c2be --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Capture/Emitter.cs @@ -0,0 +1,541 @@ +using System; +using System.Collections.Generic; +using MCPForUnity.Editor.ActionTrace.Core; +using MCPForUnity.Editor.ActionTrace.Core.Models; +using MCPForUnity.Editor.ActionTrace.Core.Store; +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 + ); + + // Apply sampling middleware to maintain consistency with ActionTraceRecorder + if (!SamplingMiddleware.ShouldRecord(evt)) + { + return; + } + + 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/Filters/EventFilter.cs b/MCPForUnity/Editor/ActionTrace/Capture/Filters/EventFilter.cs new file mode 100644 index 000000000..682beda99 --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Capture/Filters/EventFilter.cs @@ -0,0 +1,474 @@ +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; + + // P1 Fix: Cache for active rules to avoid repeated sorting + [NonSerialized] + private List _cachedActiveRules; + + [NonSerialized] + private bool _cacheDirty = true; + + /// + /// 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. + /// P1 Fix: Invalidates cache after modification. + /// + 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); + InvalidateCache(); + return rule; + } + + /// + /// Remove a rule by name. + /// P1 Fix: Invalidates cache after modification. + /// + public bool RemoveRule(string name) + { + var rule = CustomRules.Find(r => r.Name == name); + if (rule != null) + { + CustomRules.Remove(rule); + InvalidateCache(); + return true; + } + return false; + } + + /// + /// Get all active rules (default + custom, sorted by priority). + /// P1 Fix: Returns cached rules when available for better performance. + /// + public List GetActiveRules() + { + // Return cached rules if valid + if (!_cacheDirty && _cachedActiveRules != null) + return _cachedActiveRules; + + 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)); + + _cachedActiveRules = rules; + _cacheDirty = false; + return rules; + } + + /// + /// Invalidate the cached rules. Call this after modifying rules. + /// P1 Fix: Ensures cache is refreshed when rules change. + /// + public void InvalidateCache() + { + _cacheDirty = true; + } + } + + /// + /// 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/Recorder.cs b/MCPForUnity/Editor/ActionTrace/Capture/Recorder.cs new file mode 100644 index 000000000..b49a98e76 --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Capture/Recorder.cs @@ -0,0 +1,360 @@ +using System; +using System.Collections.Generic; +using MCPForUnity.Editor.ActionTrace.Core; +using MCPForUnity.Editor.ActionTrace.Core.Models; +using MCPForUnity.Editor.ActionTrace.Core.Store; +using MCPForUnity.Editor.ActionTrace.Integration.VCS; +using MCPForUnity.Editor.ActionTrace.Sources.Helpers; +using MCPForUnity.Editor.Hooks.EventArgs; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Hooks; +using UnityEditor; +using UnityEngine; +using UnityEngine.SceneManagement; + +namespace MCPForUnity.Editor.ActionTrace.Capture +{ + /// + /// Records Unity editor events to ActionTrace's EventStore. + /// Subscribes to HookRegistry events for clean separation of concerns. + /// + /// Architecture: + /// Unity Events → UnityEventHooks (detection) → HookRegistry → ActionTraceRecorder (recording) + /// + /// This allows UnityEventHooks to remain a pure detector without ActionTrace dependencies. + /// The GameObject tracking capability is injected via IGameObjectCacheProvider interface. + /// + [InitializeOnLoad] + internal static class ActionTraceRecorder + { + private static GameObjectTrackingHelper _trackingHelper; + + static ActionTraceRecorder() + { + // Initialize GameObject tracking helper + _trackingHelper = new GameObjectTrackingHelper(); + + // Inject cache provider into UnityEventHooks + var cacheProvider = new GameObjectTrackingCacheProvider(_trackingHelper); + Hooks.UnityEventHooks.SetGameObjectCacheProvider(cacheProvider); + + // Subscribe to cleanup events + AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload; + + // Subscribe to HookRegistry events + HookRegistry.OnComponentAdded += OnComponentAdded; + HookRegistry.OnComponentRemoved += OnComponentRemoved; + HookRegistry.OnComponentRemovedDetailed += OnComponentRemovedDetailed; + HookRegistry.OnGameObjectCreated += OnGameObjectCreated; + // Note: We only use OnGameObjectDestroyedDetailed since it has complete cached data + // OnGameObjectDestroyed is called first with null, so we skip it to avoid duplicates + HookRegistry.OnGameObjectDestroyedDetailed += OnGameObjectDestroyedDetailed; + HookRegistry.OnSelectionChanged += OnSelectionChanged; + HookRegistry.OnHierarchyChanged += OnHierarchyChanged; + HookRegistry.OnPlayModeChanged += OnPlayModeChanged; + HookRegistry.OnSceneSaved += OnSceneSaved; + HookRegistry.OnSceneOpenedDetailed += OnSceneOpenedDetailed; + HookRegistry.OnNewSceneCreatedDetailed += OnNewSceneCreatedDetailed; + HookRegistry.OnScriptCompiledDetailed += OnScriptCompiledDetailed; + HookRegistry.OnScriptCompilationFailedDetailed += OnScriptCompilationFailedDetailed; + HookRegistry.OnBuildCompletedDetailed += OnBuildCompletedDetailed; + } + + private static void OnBeforeAssemblyReload() + { + // Clear GameObject tracking helper state before reload + _trackingHelper?.Reset(); + + // Clear cache provider from UnityEventHooks to release references + Hooks.UnityEventHooks.SetGameObjectCacheProvider(null); + + // Unsubscribe from HookRegistry events before domain reload + HookRegistry.OnComponentAdded -= OnComponentAdded; + HookRegistry.OnComponentRemoved -= OnComponentRemoved; + HookRegistry.OnComponentRemovedDetailed -= OnComponentRemovedDetailed; + HookRegistry.OnGameObjectCreated -= OnGameObjectCreated; + HookRegistry.OnGameObjectDestroyedDetailed -= OnGameObjectDestroyedDetailed; + HookRegistry.OnSelectionChanged -= OnSelectionChanged; + HookRegistry.OnHierarchyChanged -= OnHierarchyChanged; + HookRegistry.OnPlayModeChanged -= OnPlayModeChanged; + HookRegistry.OnSceneSaved -= OnSceneSaved; + HookRegistry.OnSceneOpenedDetailed -= OnSceneOpenedDetailed; + HookRegistry.OnNewSceneCreatedDetailed -= OnNewSceneCreatedDetailed; + HookRegistry.OnScriptCompiledDetailed -= OnScriptCompiledDetailed; + HookRegistry.OnScriptCompilationFailedDetailed -= OnScriptCompilationFailedDetailed; + HookRegistry.OnBuildCompletedDetailed -= OnBuildCompletedDetailed; + + // Unsubscribe from cleanup event + AssemblyReloadEvents.beforeAssemblyReload -= OnBeforeAssemblyReload; + } + + #region Hook Handlers + + private static void OnComponentAdded(Component component) + { + if (component == null) return; + + var goName = component.gameObject != null ? component.gameObject.name : "Unknown"; + var payload = new Dictionary + { + ["component_type"] = component.GetType().Name, + ["name"] = goName + }; + + string globalId = GlobalIdHelper.ToGlobalIdString(component); + RecordEvent("ComponentAdded", globalId, payload); + } + + private static void OnComponentRemoved(Component component) + { + if (component == null) return; + + var goName = component.gameObject != null ? component.gameObject.name : "Unknown"; + var payload = new Dictionary + { + ["component_type"] = component.GetType().Name, + ["name"] = goName + }; + + string globalId = GlobalIdHelper.ToGlobalIdString(component); + RecordEvent("ComponentRemoved", globalId, payload); + } + + private static void OnComponentRemovedDetailed(ComponentRemovedArgs args) + { + if (args == null) return; + + var goName = args.Owner != null ? args.Owner.name : "Unknown"; + var payload = new Dictionary + { + ["component_type"] = args.ComponentType ?? "Unknown", + ["name"] = goName, + ["component_instance_id"] = args.ComponentInstanceId + }; + + string targetId = args.Owner != null + ? GlobalIdHelper.ToGlobalIdString(args.Owner) + : args.ComponentInstanceId.ToString(); + + RecordEvent("ComponentRemoved", targetId, payload); + } + + private static void OnGameObjectCreated(GameObject go) + { + if (go == null) return; + + var payload = new Dictionary + { + ["name"] = go.name, + ["tag"] = go.tag, + ["layer"] = go.layer, + ["scene"] = go.scene.name, + ["is_prefab"] = PrefabUtility.IsPartOfAnyPrefab(go) + }; + + string globalId = GlobalIdHelper.ToGlobalIdString(go); + RecordEvent("GameObjectCreated", globalId, payload); + } + + private static void OnGameObjectDestroyedDetailed(GameObjectDestroyedArgs args) + { + if (args == null) return; + + var payload = new Dictionary + { + ["name"] = args.Name ?? "Unknown", + ["instance_id"] = args.InstanceId, + ["destroyed"] = true + }; + + string targetId = args.GlobalId ?? $"Instance:{args.InstanceId}"; + RecordEvent("GameObjectDestroyed", targetId, payload); + } + + private static void OnSelectionChanged(GameObject selectedGo) + { + // Use the parameter instead of Selection.activeObject (more efficient and reliable) + if (selectedGo == null) return; + + var payload = new Dictionary + { + ["name"] = selectedGo.name, + ["type"] = selectedGo.GetType().Name, + ["instance_id"] = selectedGo.GetInstanceID(), + ["path"] = GetGameObjectPath(selectedGo) + }; + + string globalId = GlobalIdHelper.ToGlobalIdString(selectedGo); + RecordEvent("SelectionChanged", globalId, payload); + } + + private static void OnHierarchyChanged() + { + RecordEvent("HierarchyChanged", "Scene", new Dictionary()); + } + + private static void OnPlayModeChanged(bool isPlaying) + { + var state = isPlaying ? PlayModeStateChange.EnteredPlayMode : PlayModeStateChange.ExitingPlayMode; + var payload = new Dictionary + { + ["state"] = state.ToString() + }; + + RecordEvent("PlayModeChanged", "Editor", payload); + } + + private static void OnSceneSaved(Scene scene) + { + var path = scene.path; + var targetId = string.IsNullOrEmpty(path) ? scene.name : $"Asset:{path}"; + var payload = new Dictionary + { + ["scene_name"] = scene.name, + ["path"] = path, + ["root_count"] = scene.rootCount + }; + + RecordEvent("SceneSaved", targetId, payload); + } + + private static void OnSceneOpenedDetailed(Scene scene, SceneOpenArgs args) + { + var mode = args.Mode.GetValueOrDefault(global::UnityEditor.SceneManagement.OpenSceneMode.Single); + var path = scene.path; + var 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("SceneOpened", targetId, payload); + } + + private static void OnNewSceneCreatedDetailed(Scene scene, NewSceneArgs args) + { + var setup = args.Setup.GetValueOrDefault(global::UnityEditor.SceneManagement.NewSceneSetup.DefaultGameObjects); + var mode = args.Mode.GetValueOrDefault(global::UnityEditor.SceneManagement.NewSceneMode.Single); + var payload = new Dictionary + { + ["scene_name"] = scene.name, + ["setup"] = setup.ToString(), + ["mode"] = mode.ToString() + }; + + RecordEvent("NewSceneCreated", $"Scene:{scene.name}", payload); + } + + private static void OnScriptCompiledDetailed(ScriptCompilationArgs args) + { + var payload = new Dictionary + { + ["script_count"] = args.ScriptCount ?? 0, + ["duration_ms"] = args.DurationMs ?? 0 + }; + + RecordEvent("ScriptCompiled", "Editor", payload); + } + + private static void OnScriptCompilationFailedDetailed(ScriptCompilationFailedArgs args) + { + var payload = new Dictionary + { + ["script_count"] = args.ScriptCount ?? 0, + ["duration_ms"] = args.DurationMs ?? 0, + ["error_count"] = args.ErrorCount + }; + + RecordEvent("ScriptCompilationFailed", "Editor", payload); + } + + private static void OnBuildCompletedDetailed(BuildArgs args) + { + if (args.Success) + { + var payload = new Dictionary + { + ["platform"] = args.Platform, + ["location"] = args.Location, + ["duration_ms"] = args.DurationMs ?? 0, + ["size_bytes"] = args.SizeBytes ?? 0, + ["size_mb"] = (args.SizeBytes ?? 0) / (1024.0 * 1024.0) + }; + + RecordEvent("BuildCompleted", "Build", payload); + } + else + { + var payload = new Dictionary + { + ["platform"] = args.Platform, + ["location"] = args.Location, + ["duration_ms"] = args.DurationMs ?? 0, + ["error"] = args.Summary ?? "Build failed" + }; + + RecordEvent("BuildFailed", "Build", payload); + } + } + + #endregion + + #region Event Recording + + private static void RecordEvent(string type, string targetId, Dictionary payload) + { + try + { + // Inject VCS context if available + var vcsContext = VcsContextProvider.GetCurrentContext(); + if (vcsContext != null) + { + payload["vcs_context"] = vcsContext.ToDictionary(); + } + + // Create event + var evt = new EditorEvent( + 0, // sequence (assigned by EventStore) + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + type, + targetId, + payload + ); + + // Apply sampling middleware + if (!SamplingMiddleware.ShouldRecord(evt)) + { + return; + } + + // Record to EventStore + EventStore.Record(evt); + } + catch (Exception ex) + { + McpLog.Warn($"[ActionTraceRecorder] Recording failed: {ex.Message}"); + } + } + + 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; + } + + #endregion + } +} diff --git a/MCPForUnity/Editor/ActionTrace/Capture/Sampling/PendingSample.cs b/MCPForUnity/Editor/ActionTrace/Capture/Sampling/PendingSample.cs new file mode 100644 index 000000000..e25b8c2c9 --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Capture/Sampling/PendingSample.cs @@ -0,0 +1,20 @@ +using MCPForUnity.Editor.ActionTrace.Core.Models; + +namespace MCPForUnity.Editor.ActionTrace.Capture +{ + /// + /// 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/Sampling/SamplingConfig.cs b/MCPForUnity/Editor/ActionTrace/Capture/Sampling/SamplingConfig.cs new file mode 100644 index 000000000..6cd3e2e21 --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Capture/Sampling/SamplingConfig.cs @@ -0,0 +1,105 @@ +using System.Collections.Concurrent; +using System.Collections.Generic; +using MCPForUnity.Editor.ActionTrace.Core; + +namespace MCPForUnity.Editor.ActionTrace.Capture +{ + /// + /// Static configuration for sampling strategies. + /// Event types can be registered with their desired sampling behavior. + /// + /// Sampling intervals are configured via hardcoded constants. + /// To customize sampling behavior, modify InitializeStrategies() directly. + /// + public static class SamplingConfig + { + // Hardcoded sampling intervals (milliseconds) + private const int HierarchySamplingMs = 1000; + private const int SelectionSamplingMs = 500; + private const int PropertySamplingMs = 200; + + /// + /// Default sampling strategies for common event types. + /// Configured to prevent event floods while preserving important data. + /// Thread-safe: uses ConcurrentDictionary to prevent race conditions + /// when accessed from EditorApplication.update and event emitters simultaneously. + /// + public static readonly ConcurrentDictionary Strategies = new( + InitializeStrategies() + ); + + /// + /// Initializes sampling strategies with hardcoded values. + /// + private static Dictionary InitializeStrategies() + { + return new Dictionary + { + // Hierarchy changes: Throttle to 1 event per second + { + EventTypes.HierarchyChanged, + new SamplingStrategy(SamplingMode.Throttle, HierarchySamplingMs) + }, + + // Selection changes: Throttle to 1 event per 500ms + { + EventTypes.SelectionChanged, + new SamplingStrategy(SamplingMode.Throttle, SelectionSamplingMs) + }, + + // Property modifications: Debounce by key (200ms window) + // Note: PropertyChangeTracker also implements its own debounce window, + // so this provides additional protection against event storms. + { + EventTypes.PropertyModified, + new SamplingStrategy(SamplingMode.DebounceByKey, PropertySamplingMs) + }, + + // 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.TryRemove(eventType, out _); + } + + /// + /// 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); + } + } +} diff --git a/MCPForUnity/Editor/ActionTrace/Capture/Sampling/SamplingMiddleware.cs b/MCPForUnity/Editor/ActionTrace/Capture/Sampling/SamplingMiddleware.cs new file mode 100644 index 000000000..cc2826cb7 --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Capture/Sampling/SamplingMiddleware.cs @@ -0,0 +1,363 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using MCPForUnity.Editor.ActionTrace.Core; +using MCPForUnity.Editor.ActionTrace.Core.Models; +using MCPForUnity.Editor.ActionTrace.Core.Store; +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 + // MaxSampleCache: ~2-4 seconds of sampling at 30Hz event rate (128 samples) + // Prevents unbounded memory growth while allowing adequate debounce window coverage + private const int MaxSampleCache = 128; + + // CleanupAgeMs: 2 seconds - balance between freshness and overhead + // Old enough to cover most debounce windows (typically 500-1000ms) but short enough to prevent stale data + private const long CleanupAgeMs = 2000; + + // FlushCheckIntervalMs: 200ms - responsive without overwhelming the main thread + // Checks 5 times per second to detect expired debounce samples while keeping CPU impact minimal + private const long FlushCheckIntervalMs = 200; + + // 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 (prefer payload, fallback to TargetId) + string assetPath = null; + if (evt.Payload != null && evt.Payload.TryGetValue("path", out var pathVal)) + { + assetPath = pathVal?.ToString(); + } + + // Fallback to TargetId and strip "Asset:" prefix if present + if (string.IsNullOrEmpty(assetPath) && !string.IsNullOrEmpty(evt.TargetId)) + { + assetPath = evt.TargetId.StartsWith("Asset:") ? evt.TargetId.Substring(6) : evt.TargetId; + } + + 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; + PendingSample oldestSample = default; + foreach (var kvp in _pendingSamples) + { + if (kvp.Value.TimestampMs < oldestTimestamp) + { + oldestTimestamp = kvp.Value.TimestampMs; + oldestKey = kvp.Key; + oldestSample = kvp.Value; + } + } + if (!string.IsNullOrEmpty(oldestKey) && _pendingSamples.TryRemove(oldestKey, out var removedSample)) + { + // Record evicted debounce samples to prevent data loss + if (SamplingConfig.Strategies.TryGetValue(removedSample.Event.Type, out var evictedStrategy) && + (evictedStrategy.Mode == SamplingMode.Debounce || evictedStrategy.Mode == SamplingMode.DebounceByKey)) + { + EventStore.Record(removedSample.Event); + } + } + } + } + + // 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. + /// + /// For Debounce/DebounceByKey modes: uses strategy-specific WindowMs to avoid + /// dropping samples before they can be flushed by FlushExpiredDebounceSamples. + /// For other modes: uses CleanupAgeMs as a fallback. + /// + private static void CleanupExpiredSamples(long nowMs) + { + // Directly remove expired samples without intermediate list + foreach (var kvp in _pendingSamples) + { + long ageMs = nowMs - kvp.Value.TimestampMs; + + // Check if this sample has a strategy configured + if (SamplingConfig.Strategies.TryGetValue(kvp.Value.Event.Type, out var strategy)) + { + // For Debounce modes, respect the strategy's WindowMs + // This prevents samples from being deleted before FlushExpiredDebounceSamples can record them + if (strategy.Mode == SamplingMode.Debounce || strategy.Mode == SamplingMode.DebounceByKey) + { + // Only remove if significantly older than the window (2x window as safety margin) + if (ageMs > strategy.WindowMs * 2) + { + _pendingSamples.TryRemove(kvp.Key, out _); + } + // For debounce samples within the window, don't clean up + continue; + } + + // For Throttle mode, use the larger of strategy window or cleanup age + if (strategy.Mode == SamplingMode.Throttle) + { + if (ageMs > Math.Max(strategy.WindowMs, CleanupAgeMs)) + { + _pendingSamples.TryRemove(kvp.Key, out _); + } + continue; + } + } + + // Fallback: use CleanupAgeMs for samples without a strategy + if (ageMs > 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(); + } + } +} diff --git a/MCPForUnity/Editor/ActionTrace/Capture/Sampling/SamplingStrategy.cs b/MCPForUnity/Editor/ActionTrace/Capture/Sampling/SamplingStrategy.cs new file mode 100644 index 000000000..59a232adb --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Capture/Sampling/SamplingStrategy.cs @@ -0,0 +1,28 @@ +using MCPForUnity.Editor.ActionTrace.Core; + +namespace MCPForUnity.Editor.ActionTrace.Capture +{ + /// + /// 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; + } + } +} 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/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/EventTypes.cs b/MCPForUnity/Editor/ActionTrace/Core/EventTypes.cs new file mode 100644 index 000000000..36d4c5902 --- /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: {component_type} from {name} (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: {component_type} to {name} (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/Models/EditorEvent.cs b/MCPForUnity/Editor/ActionTrace/Core/Models/EditorEvent.cs new file mode 100644 index 000000000..3d3d785c6 --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Core/Models/EditorEvent.cs @@ -0,0 +1,397 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using MCPForUnity.Editor.ActionTrace.Analysis.Summarization; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json; + +namespace MCPForUnity.Editor.ActionTrace.Core.Models +{ + /// + /// 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 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/Presets/ActionTracePreset.cs b/MCPForUnity/Editor/ActionTrace/Core/Presets/ActionTracePreset.cs new file mode 100644 index 000000000..5853bb821 --- /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.Presets +{ + /// + /// 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/Core/Settings/ActionTraceSettings.cs b/MCPForUnity/Editor/ActionTrace/Core/Settings/ActionTraceSettings.cs new file mode 100644 index 000000000..520985044 --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Core/Settings/ActionTraceSettings.cs @@ -0,0 +1,273 @@ +using System; +using System.Collections.Generic; +using UnityEngine; +using UnityEditor; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.ActionTrace.Core.Presets; + +namespace MCPForUnity.Editor.ActionTrace.Core.Settings +{ + /// + /// 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(); + } + + /// + /// 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. Events are kept in sequence order; the first N events (oldest) have full payload, while events beyond this limit are dehydrated (Payload=null).")] + public int HotEventCount = 150; + } + + // SamplingSettings is deprecated. Sampling is now configured via SamplingConfig + // with hardcoded values to avoid runtime configuration complexity. + // + // [Serializable] + // public sealed class SamplingSettings + // { + // public bool EnableSampling = true; + // public float SamplingImportanceThreshold = 0.3f; + // public int HierarchySamplingMs = 1000; + // public int SelectionSamplingMs = 500; + // 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 ========== + + [Header("Event Filtering")] + [Tooltip("Controls which events are recorded based on importance and type")] + public FilteringSettings Filtering = new(); + + [Header("Event Merging")] + [Tooltip("Controls how high-frequency events are combined")] + public MergingSettings Merging = new(); + + [Header("Storage & Memory")] + [Tooltip("Controls event storage limits and memory management")] + public StorageSettings Storage = new(); + + // Sampling is configured via SamplingConfig with hardcoded values + // To customize, modify SamplingConfig.InitializeStrategies() directly + // 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; + } + + /// + /// 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/Store/EventStore.Context.cs b/MCPForUnity/Editor/ActionTrace/Core/Store/EventStore.Context.cs new file mode 100644 index 000000000..115517703 --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Core/Store/EventStore.Context.cs @@ -0,0 +1,188 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MCPForUnity.Editor.ActionTrace.Context; +using MCPForUnity.Editor.ActionTrace.Core.Models; +using MCPForUnity.Editor.ActionTrace.Core.Settings; + +namespace MCPForUnity.Editor.ActionTrace.Core.Store +{ + /// + /// 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 + // Store all mappings per eventSequence (not just FirstOrDefault) to preserve + // multiple contexts for the same event (multi-agent collaboration scenario) + var mappingBySequence = mappingsSnapshot + .GroupBy(m => m.EventSequence) + .ToDictionary(g => g.Key, g => g.ToList()); + + // Query and join outside lock + var query = eventsSnapshot.AsEnumerable(); + + if (sinceSequence.HasValue) + { + query = query.Where(e => e.Sequence > sinceSequence.Value); + } + + // SelectMany ensures each event-context pair becomes a separate tuple. + // An event with 3 contexts yields 3 tuples, preserving all context data. + var results = query + .OrderByDescending(e => e.Sequence) + .Take(limit) + .SelectMany(e => + { + if (mappingBySequence.TryGetValue(e.Sequence, out var mappings) && mappings.Count > 0) + return mappings.Select(m => (Event: e, Context: m)); + return new[] { (Event: e, Context: (ContextMapping)null) }; + }) + .ToList(); + + return results; + } + } +} diff --git a/MCPForUnity/Editor/ActionTrace/Core/Store/EventStore.Diagnostics.cs b/MCPForUnity/Editor/ActionTrace/Core/Store/EventStore.Diagnostics.cs new file mode 100644 index 000000000..a5b6e1f68 --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Core/Store/EventStore.Diagnostics.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using MCPForUnity.Editor.ActionTrace.Core.Settings; +using MCPForUnity.Editor.ActionTrace.Semantics; + +namespace MCPForUnity.Editor.ActionTrace.Core.Store +{ + /// + /// 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() while holding _queryLock. + /// + /// Thread safety: Caller must hold _queryLock before calling. + /// All _events access happens within the same lock to prevent concurrent modification. + /// + private static void DehydrateOldEvents(int hotEventCount) + { + // Clamp to non-negative to prevent negative iteration + hotEventCount = Math.Max(0, hotEventCount); + + lock (_queryLock) + { + int count = _events.Count; + int dehydrateLimit = Math.Max(0, count - hotEventCount); + + // Find events that need dehydration (not already dehydrated and beyond hot count) + for (int i = 0; i < dehydrateLimit; 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 != null ? settings.Storage.HotEventCount : 100; + int maxEvents = settings != null ? 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) + { + // Skip null entries (DehydrateOldEvents can leave nulls) + if (evt == null) + continue; + + 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/Store/EventStore.Merging.cs b/MCPForUnity/Editor/ActionTrace/Core/Store/EventStore.Merging.cs new file mode 100644 index 000000000..fc755fb74 --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Core/Store/EventStore.Merging.cs @@ -0,0 +1,171 @@ +using System; +using System.Collections.Generic; +using MCPForUnity.Editor.ActionTrace.Core; +using MCPForUnity.Editor.ActionTrace.Core.Models; +using MCPForUnity.Editor.ActionTrace.Core.Settings; +using MCPForUnity.Editor.ActionTrace.Semantics; +using UnityEditor; + +namespace MCPForUnity.Editor.ActionTrace.Core.Store +{ + /// + /// 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() + { + // UI/Editor events (existing) + EventTypes.PropertyModified, + EventTypes.SelectionPropertyModified, + EventTypes.HierarchyChanged, + EventTypes.SelectionChanged, + + // Asset events (added for deduplication) + // Solves duplicate logs during script reload, .meta refresh, and re-imports + // Note: AssetDeleted/Moved remain EXCLUDED as they are one-time structural events + EventTypes.AssetCreated, + EventTypes.AssetImported, + EventTypes.AssetModified + }; + + /// + /// 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 + // Handle deserialization type variance (int/long/double/string) + int mergeCount = 1; + if (_lastRecordedEvent.Payload.TryGetValue("merge_count", out var existingCount)) + { + mergeCount = existingCount switch + { + int i => i + 1, + long l => checked((int)l + 1), + double d => checked((int)d + 1), + string s when int.TryParse(s, out int parsed) => parsed + 1, + _ => 2 // Fallback for unknown types + }; + } + 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/Store/EventStore.Persistence.cs b/MCPForUnity/Editor/ActionTrace/Core/Store/EventStore.Persistence.cs new file mode 100644 index 000000000..dbb6badfa --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Core/Store/EventStore.Persistence.cs @@ -0,0 +1,221 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MCPForUnity.Editor.ActionTrace.Context; +using MCPForUnity.Editor.ActionTrace.Core.Models; +using MCPForUnity.Editor.ActionTrace.Core.Settings; +using MCPForUnity.Editor.Helpers; +using UnityEditor; + +namespace MCPForUnity.Editor.ActionTrace.Core.Store +{ + /// + /// 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; + + // P1 Fix: Save throttling to prevent excessive disk writes + private const long MinSaveIntervalMs = 1000; // Minimum 1 second between saves + + private static bool _isLoaded; + private static bool _saveScheduled; // Prevents duplicate delayCall registrations + private static long _lastSaveTime; // Track last save time for throttling + + /// + /// 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. + /// P1 Fix: Added throttling to prevent excessive disk writes. + /// + private static void ScheduleSave() + { + // P1 Fix: Check throttling - skip if too soon since last save + long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + if (now - _lastSaveTime < MinSaveIntervalMs) + { + // Too soon, just mark dirty and skip save + return; + } + + // 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; + // P1 Fix: Update last save time when actually saving + _lastSaveTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + } + } + + // 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. + /// + 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/Store/EventStore.cs b/MCPForUnity/Editor/ActionTrace/Core/Store/EventStore.cs new file mode 100644 index 000000000..491dd6e32 --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Core/Store/EventStore.cs @@ -0,0 +1,365 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using MCPForUnity.Editor.ActionTrace.Context; +using MCPForUnity.Editor.ActionTrace.Core; +using MCPForUnity.Editor.ActionTrace.Core.Models; +using MCPForUnity.Editor.ActionTrace.Core.Settings; +using MCPForUnity.Editor.ActionTrace.Semantics; +using UnityEditor; + +namespace MCPForUnity.Editor.ActionTrace.Core.Store +{ + /// + /// 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 + // P1 Fix: Added max limit to prevent unbounded growth + private const int MaxPendingNotifications = 256; // Max pending notifications before forced drain + private static readonly List _pendingNotifications = new(); + private static bool _notifyScheduled; + + // 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 with limit check (P1 Fix) + lock (_pendingNotifications) + { + // P1 Fix: Force drain if over limit to prevent unbounded growth + if (_pendingNotifications.Count >= MaxPendingNotifications) + { + _notifyScheduled = false; // Reset flag so we can drain immediately + } + + _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 + // Use checked arithmetic to detect overflow, fall back to full list if overflow occurs + int copyCount; + try + { + int windowSize = checked(limit + (limit / 10) + 10); + copyCount = Math.Min(count, windowSize); + } + catch (OverflowException) + { + // If limit is too large (e.g., int.MaxValue), just take all events + copyCount = count; + } + 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(); + } + + /// + /// Query all events without limit. Returns events in sequence order (newest first). + /// This is more efficient than Query(int.MaxValue) as it avoids overflow checks. + /// Thread-safe - can be called from any thread. + /// + public static IReadOnlyList QueryAll() + { + List snapshot; + + lock (_queryLock) + { + int count = _events.Count; + if (count == 0) + return Array.Empty(); + + // Create a snapshot of all events + snapshot = new List(count); + for (int i = 0; i < count; i++) + { + snapshot.Add(_events[i]); + } + } + + // Return in sequence order (newest first) + // Note: snapshot is already in sequence order (ascending), so we reverse + snapshot.Reverse(); + return snapshot; + } + + /// + /// 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/Helpers/ActionTraceHelper.cs b/MCPForUnity/Editor/ActionTrace/Helpers/ActionTraceHelper.cs new file mode 100644 index 000000000..ac4b3b6fe --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Helpers/ActionTraceHelper.cs @@ -0,0 +1,96 @@ +using System; +using UnityEngine; +using UnityEditor; +using MCPForUnity.Editor.ActionTrace.Core.Models; + +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 + { + /// + /// Extracts a string value from event Payload by key. + /// Returns defaultValue (null) if not present or value is null. + /// + public static string GetPayloadString(this EditorEvent evt, string key, string defaultValue = null) + { + if (evt.Payload == null) + return defaultValue; + + if (evt.Payload.TryGetValue(key, out var value)) + return value?.ToString(); + + return defaultValue; + } + /// + /// 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) + /// + 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/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/PropertyModificationHelper.cs b/MCPForUnity/Editor/ActionTrace/Helpers/PropertyModificationHelper.cs new file mode 100644 index 000000000..76bc1f4a8 --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Helpers/PropertyModificationHelper.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 for property change tracking. + /// + public static class PropertyModificationHelper + { + /// + /// 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 modification) + { + // Try direct 'currentValue.target' path + var result = GetNestedValue(modification, "currentValue.target"); + if (result is UnityEngine.Object obj) return obj; + + // Fallback to 'previousValue.target' + result = GetNestedValue(modification, "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 modification) + { + var result = GetNestedValue(modification, "currentValue.propertyPath"); + if (result != null) return result as string; + + result = GetNestedValue(modification, "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 modification) + { + // Try direct 'currentValue.value' path + var result = GetNestedValue(modification, "currentValue.value"); + if (result != null) return result; + + return GetNestedValue(modification, "currentValue"); + } + + /// + /// Extracts previous (old) value from an UndoPropertyModification. + /// This is value before modification was applied. + /// + public static object GetPreviousValue(UndoPropertyModification modification) + { + // Try 'previousValue.value' (nested structure) first - matches GetCurrentValue pattern + var result = GetNestedValue(modification, "previousValue.value"); + if (result != null) return result; + + // Fallback to direct 'previousValue' property + return GetNestedValue(modification, "previousValue"); + } + } +} diff --git a/MCPForUnity/Editor/ActionTrace/Integration/AssetBridge.cs b/MCPForUnity/Editor/ActionTrace/Integration/AssetBridge.cs new file mode 100644 index 000000000..776216961 --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Integration/AssetBridge.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/Integration/VCS/VcsContextProvider.cs b/MCPForUnity/Editor/ActionTrace/Integration/VCS/VcsContextProvider.cs new file mode 100644 index 000000000..5b57085da --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Integration/VCS/VcsContextProvider.cs @@ -0,0 +1,335 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using UnityEditor; +using UnityEngine; +using MCPForUnity.Editor.Helpers; + +namespace MCPForUnity.Editor.ActionTrace.Integration.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.update) + /// - Injects vcs_context into event payloads + /// - Supports Git-only (Unity Collaborate, SVN, Perforce not implemented) + /// + /// Unity 6 Compatibility: + /// - Uses [InitializeOnLoad] to ensure EditorApplication.update is re-registered after domain reloads + /// - Static constructor is called by Unity on domain reload + /// + /// Event payload format: + /// { + /// "sequence": 123, + /// "summary": "Added Rigidbody to Player", + /// "vcs_context": { + /// "commit_id": "abc123", + /// "branch": "feature/player-movement", + /// "is_dirty": true + /// } + /// } + /// + [InitializeOnLoad] + 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/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..cc348bce0 --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Semantics/DefaultEventScorer.cs @@ -0,0 +1,108 @@ +using System; +using MCPForUnity.Editor.ActionTrace.Core; +using MCPForUnity.Editor.ActionTrace.Core.Models; +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..14020041a --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Semantics/DefaultIntentInferrer.cs @@ -0,0 +1,165 @@ +using System; +using System.Collections.Generic; +using MCPForUnity.Editor.ActionTrace.Core; +using MCPForUnity.Editor.ActionTrace.Core.Models; + +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) && ext != null) + return ext.ToString() == ".cs"; + if (e.Payload.TryGetValue("component_type", out var type) && type != null) + return type.ToString()?.Contains("MonoBehaviour") == true; + return false; + } + + private static bool IsScene(EditorEvent e) + { + if (e.Payload.TryGetValue("extension", out var ext) && ext != null) + return ext.ToString() == ".unity"; + return false; + } + + private static bool IsPrefab(EditorEvent e) + { + if (e.Payload.TryGetValue("extension", out var ext) && ext != null) + return ext.ToString() == ".prefab"; + return false; + } + + private static bool IsTexture(EditorEvent e) + { + if (e.Payload.TryGetValue("extension", out var ext) && ext != null) + { + 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) && type != null) + return type.ToString()?.Contains("Texture") == true; + return false; + } + + private static bool IsAudio(EditorEvent e) + { + if (e.Payload.TryGetValue("extension", out var ext) && ext != null) + { + 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) && type != null) + { + 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) && type != null) + { + 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..80e908c68 --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Semantics/IEventScorer.cs @@ -0,0 +1,21 @@ +using MCPForUnity.Editor.ActionTrace.Core; +using MCPForUnity.Editor.ActionTrace.Core.Models; + +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..f62515439 --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Semantics/IIntentInferrer.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using MCPForUnity.Editor.ActionTrace.Core; +using MCPForUnity.Editor.ActionTrace.Core.Models; + +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/Sources/Helpers/GameObjectTrackingCacheProvider.cs b/MCPForUnity/Editor/ActionTrace/Sources/Helpers/GameObjectTrackingCacheProvider.cs new file mode 100644 index 000000000..a430dbc28 --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Sources/Helpers/GameObjectTrackingCacheProvider.cs @@ -0,0 +1,61 @@ +using System; +using UnityEngine; +using MCPForUnity.Editor.Hooks; + +namespace MCPForUnity.Editor.ActionTrace.Sources.Helpers +{ + /// + /// ActionTrace's implementation of IGameObjectCacheProvider. + /// Adapter that wraps GameObjectTrackingHelper to provide the interface. + /// + internal sealed class GameObjectTrackingCacheProvider : IGameObjectCacheProvider + { + private readonly GameObjectTrackingHelper _helper; + + public GameObjectTrackingCacheProvider(GameObjectTrackingHelper helper) + { + _helper = helper ?? throw new ArgumentNullException(nameof(helper)); + } + + public string GetCachedName(int instanceId) + { + return _helper.GetCachedName(instanceId); + } + + public string GetCachedGlobalId(int instanceId) + { + return _helper.GetCachedGlobalId(instanceId); + } + + public void RegisterGameObject(GameObject gameObject) + { + // Cache the GameObject for later component removal tracking + // (This is handled by UnityEventHooks.Advanced.TrackComponentRemoval) + } + + public void DetectChanges(Action onCreated, Action onDestroyed) + { + var result = _helper.DetectChanges(); + + foreach (var change in result.Changes) + { + if (change.isNew) onCreated?.Invoke(change.obj); + } + + foreach (int id in result.DestroyedIds) + { + onDestroyed?.Invoke(id); + } + } + + public void InitializeTracking() + { + _helper.InitializeTracking(); + } + + public void Reset() + { + _helper.Reset(); + } + } +} diff --git a/MCPForUnity/Editor/ActionTrace/Sources/Helpers/GameObjectTrackingHelper.cs b/MCPForUnity/Editor/ActionTrace/Sources/Helpers/GameObjectTrackingHelper.cs new file mode 100644 index 000000000..dc91b0d12 --- /dev/null +++ b/MCPForUnity/Editor/ActionTrace/Sources/Helpers/GameObjectTrackingHelper.cs @@ -0,0 +1,201 @@ +using System; +using System.Collections.Generic; +using MCPForUnity.Editor.ActionTrace.Core; +using MCPForUnity.Editor.ActionTrace.Core.Models; +using MCPForUnity.Editor.Helpers; +using UnityEngine; + +namespace MCPForUnity.Editor.ActionTrace.Sources.Helpers +{ + /// + /// Result of GameObject change detection. + /// + internal readonly struct GameObjectChangeResult + { + public List<(GameObject obj, bool isNew)> Changes { get; } + public List DestroyedIds { get; } + /// + /// InstanceID -> Name mapping for destroyed GameObjects. + /// Used to preserve names after objects are destroyed. + /// + public Dictionary DestroyedNames { get; } + /// + /// InstanceID -> GlobalID mapping for destroyed GameObjects. + /// Used to preserve cross-session stable IDs after objects are destroyed. + /// + public Dictionary DestroyedGlobalIds { get; } + + public GameObjectChangeResult(List<(GameObject, bool)> changes, List destroyedIds, Dictionary destroyedNames, Dictionary destroyedGlobalIds) + { + Changes = changes; + DestroyedIds = destroyedIds; + DestroyedNames = destroyedNames ?? new Dictionary(); + DestroyedGlobalIds = destroyedGlobalIds ?? new Dictionary(); + } + } + + /// + /// Helper for tracking GameObject creation and destruction. + /// Uses HashSet for O(1) lookup instead of List.Contains O(n). + /// Also caches GameObject names and GlobalIDs for destroyed objects to preserve context. + /// + internal sealed class GameObjectTrackingHelper + { + private HashSet _previousInstanceIds = new(256); + /// + /// InstanceID -> Name cache for GameObject name preservation after destruction. + /// + private Dictionary _nameCache = new(256); + /// + /// InstanceID -> GlobalID cache for cross-session stable ID preservation. + /// Cached during the object's lifetime to enable retrieval after destruction. + /// + private Dictionary _globalIdCache = new(256); + private bool _hasInitialized; + + public void InitializeTracking() + { + if (_hasInitialized) return; + + _previousInstanceIds.Clear(); + _previousInstanceIds.EnsureCapacity(256); + _nameCache.Clear(); + _nameCache = new Dictionary(256); + _globalIdCache.Clear(); + _globalIdCache = new Dictionary(256); + + try + { + GameObject[] allObjects = GameObject.FindObjectsOfType(true); + foreach (var go in allObjects) + { + if (go != null) + { + int id = go.GetInstanceID(); + _previousInstanceIds.Add(id); + _nameCache[id] = go.name; + // Cache GlobalID for cross-session stable reference + _globalIdCache[id] = GlobalIdHelper.ToGlobalIdString(go); + } + } + } + catch (Exception ex) + { + McpLog.Warn($"[GameObjectTrackingHelper] Failed to initialize GameObject tracking: {ex.Message}"); + } + + _hasInitialized = true; + } + + public void Reset() + { + _previousInstanceIds.Clear(); + _nameCache.Clear(); + _globalIdCache.Clear(); + _hasInitialized = false; + } + + /// + /// Get cached name for a GameObject by InstanceID. + /// Used by IGameObjectCacheProvider implementation. + /// + public string GetCachedName(int instanceId) + { + return _nameCache.TryGetValue(instanceId, out string name) ? name : null; + } + + /// + /// Get cached GlobalID for a GameObject by InstanceID. + /// Used by IGameObjectCacheProvider implementation. + /// + public string GetCachedGlobalId(int instanceId) + { + return _globalIdCache.TryGetValue(instanceId, out string globalId) ? globalId : null; + } + + public GameObjectChangeResult DetectChanges() + { + if (!_hasInitialized) + { + InitializeTracking(); + return new GameObjectChangeResult(new List<(GameObject, bool)>(0), new List(0), + new Dictionary(0), new Dictionary(0)); + } + + var changes = new List<(GameObject, bool)>(64); + var destroyedIds = new List(8); + var destroyedNames = new Dictionary(8); + var destroyedGlobalIds = new Dictionary(8); + var currentIds = new HashSet(256); + + try + { + GameObject[] currentObjects = GameObject.FindObjectsOfType(true); + + // First pass: detect new objects and build current IDs set + foreach (var go in currentObjects) + { + if (go == null) continue; + + int id = go.GetInstanceID(); + currentIds.Add(id); + + // Update name cache + _nameCache[id] = go.name; + // Update GlobalID cache (pre-death "will") + _globalIdCache[id] = GlobalIdHelper.ToGlobalIdString(go); + + bool isNew = !_previousInstanceIds.Contains(id); + changes.Add((go, isNew)); + } + + // Second pass: find destroyed objects (in previous but not in current) + foreach (int id in _previousInstanceIds) + { + if (!currentIds.Contains(id)) + { + destroyedIds.Add(id); + // Preserve name from cache before removal + if (_nameCache.TryGetValue(id, out string name)) + { + destroyedNames[id] = name; + } + else + { + destroyedNames[id] = "Unknown"; + } + // Preserve GlobalID from cache (pre-death "will") + if (_globalIdCache.TryGetValue(id, out string globalId)) + { + destroyedGlobalIds[id] = globalId; + } + else + { + destroyedGlobalIds[id] = $"Instance:{id}"; + } + } + } + + // Clean up cache: remove destroyed entries + foreach (int id in destroyedIds) + { + _nameCache.Remove(id); + _globalIdCache.Remove(id); + } + + // Update tracking for next call + _previousInstanceIds.Clear(); + foreach (int id in currentIds) + { + _previousInstanceIds.Add(id); + } + } + catch (Exception ex) + { + McpLog.Warn($"[GameObjectTrackingHelper] Failed to detect GameObject changes: {ex.Message}"); + } + + return new GameObjectChangeResult(changes, destroyedIds, destroyedNames, destroyedGlobalIds); + } + } +} 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..e5e279761 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/GlobalIdHelper.cs @@ -0,0 +1,380 @@ +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 the InstanceID from a GlobalId string. + /// Returns null if the object no longer exists or the ID is invalid. + /// Useful for ActionTrace output to provide runtime object access. + /// + public static int? GetInstanceId(string globalIdStr) + { + if (string.IsNullOrEmpty(globalIdStr)) + return null; + + var obj = FromGlobalIdString(globalIdStr); + if (obj != null) + return obj.GetInstanceID(); + + // For fallback format "Instance:{instanceId}", extract the ID directly + if (globalIdStr.StartsWith(InstancePrefix)) + { + string instanceStr = globalIdStr.Substring(InstancePrefix.Length); + if (int.TryParse(instanceStr, out int instanceId)) + return 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 "". + /// + 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 or return "" +#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: Scene path - extract object name + 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; + } + } + + // Fallback format: Asset path - extract filename + 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; + } + + // Fallback format: Instance prefix - extract ID + if (globalIdStr.StartsWith(InstancePrefix)) + { + string instanceStr = globalIdStr.Substring(InstancePrefix.Length); + if (int.TryParse(instanceStr, out int instanceId)) + return $"Instance:{instanceId}"; + } + + // Truncate long IDs for display + if (globalIdStr.Length > 50) + return globalIdStr.Substring(0, 47) + "..."; + + return globalIdStr; + } + + /// + /// Gets both InstanceID and display name in a single call for efficiency. + /// Useful when both values are needed (e.g., ActionTrace output). + /// + public static (int? instanceId, string displayName) GetInstanceInfo(string globalIdStr) + { + if (string.IsNullOrEmpty(globalIdStr)) + return (null, ""); + + var obj = FromGlobalIdString(globalIdStr); + if (obj != null) + return (obj.GetInstanceID(), obj.name); + + // Object not found - extract what we can + int? instanceId = null; + string displayName = ""; + +#if UNITY_2020_3_OR_NEWER + if (GlobalObjectId.TryParse(globalIdStr, out var globalId)) + { + var guidStr = globalId.assetGUID.ToString(); + displayName = guidStr.Length >= 8 + ? $"[{globalId.identifierType} {guidStr.Substring(0, 8)}...]" + : $"[{globalId.identifierType} {guidStr}]"; + return (null, displayName); + } +#endif + + if (globalIdStr.StartsWith(ScenePrefix)) + { + int separatorIndex = globalIdStr.IndexOf(PathSeparator); + if (separatorIndex > 0) + { + string hierarchyPath = globalIdStr.Substring(separatorIndex + 1); + int lastSlash = hierarchyPath.LastIndexOf('/'); + displayName = lastSlash >= 0 + ? hierarchyPath.Substring(lastSlash + 1) + : hierarchyPath; + } + } + else if (globalIdStr.StartsWith(AssetPrefix)) + { + string assetPath = globalIdStr.Substring(AssetPrefix.Length); + int lastSlash = assetPath.LastIndexOf('/'); + displayName = lastSlash >= 0 + ? assetPath.Substring(lastSlash + 1) + : assetPath; + } + else if (globalIdStr.StartsWith(InstancePrefix)) + { + string instanceStr = globalIdStr.Substring(InstancePrefix.Length); + if (int.TryParse(instanceStr, out int parsedId)) + { + instanceId = parsedId; + displayName = $"Instance:{parsedId}"; + } + } + else if (globalIdStr.Length > 50) + { + displayName = globalIdStr.Substring(0, 47) + "..."; + } + else + { + displayName = globalIdStr; + } + + return (instanceId, displayName); + } + + /// + /// 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/Hooks/EventArgs/HookEventArgs.cs b/MCPForUnity/Editor/Hooks/EventArgs/HookEventArgs.cs new file mode 100644 index 000000000..36bdc4e07 --- /dev/null +++ b/MCPForUnity/Editor/Hooks/EventArgs/HookEventArgs.cs @@ -0,0 +1,134 @@ +using System; + +namespace MCPForUnity.Editor.Hooks.EventArgs +{ + /// + /// Base class for all hook event arguments. + /// Follows .NET conventions (similar to EventArgs). + /// + public abstract class HookEventArgs + { + /// + /// Timestamp when the event occurred. + /// + public DateTimeOffset Timestamp { get; } = DateTimeOffset.UtcNow; + } + + #region Compilation Args + + /// + /// Arguments for script compilation events. + /// + public class ScriptCompilationArgs : HookEventArgs + { + /// Number of scripts compiled (optional) + public int? ScriptCount { get; set; } + + /// Compilation duration in milliseconds (optional) + public long? DurationMs { get; set; } + } + + /// + /// Arguments for script compilation failure events. + /// + public class ScriptCompilationFailedArgs : ScriptCompilationArgs + { + /// Number of compilation errors + public int ErrorCount { get; set; } + } + + #endregion + + #region Scene Args + + /// + /// Arguments for scene open events. + /// + public class SceneOpenArgs : HookEventArgs + { + /// Mode used to open the scene (optional) + public UnityEditor.SceneManagement.OpenSceneMode? Mode { get; set; } + } + + /// + /// Arguments for new scene creation events. + /// + public class NewSceneArgs : HookEventArgs + { + /// Scene setup configuration (optional) + public UnityEditor.SceneManagement.NewSceneSetup? Setup { get; set; } + + /// New scene mode (optional) + public UnityEditor.SceneManagement.NewSceneMode? Mode { get; set; } + } + + #endregion + + #region Build Args + + /// + /// Arguments for build completion events. + /// + public class BuildArgs : HookEventArgs + { + /// Build platform name (optional) + public string Platform { get; set; } + + /// Build output location (optional) + public string Location { get; set; } + + /// Build duration in milliseconds (optional) + public long? DurationMs { get; set; } + + /// Output size in bytes (optional, only on success) + public ulong? SizeBytes { get; set; } + + /// Whether the build succeeded + public bool Success { get; set; } + + /// Build summary/error message (optional) + public string Summary { get; set; } + } + + #endregion + + #region GameObject Args + + /// + /// Arguments for GameObject destruction events. + /// Used when the GameObject has already been destroyed and is no longer accessible. + /// + public class GameObjectDestroyedArgs : HookEventArgs + { + /// The InstanceID of the destroyed GameObject (for reference) + public int InstanceId { get; set; } + + /// The name of the destroyed GameObject (cached before destruction) + public string Name { get; set; } + + /// The GlobalID of the destroyed GameObject (cached before destruction for cross-session stable reference) + public string GlobalId { get; set; } + } + + #endregion + + #region Component Args + + /// + /// Arguments for component removal events. + /// Used when the component has already been destroyed and is no longer accessible. + /// + public class ComponentRemovedArgs : HookEventArgs + { + /// The GameObject that owned the removed component + public UnityEngine.GameObject Owner { get; set; } + + /// The InstanceID of the removed component (for reference) + public int ComponentInstanceId { get; set; } + + /// The type name of the removed component (cached before destruction) + public string ComponentType { get; set; } + } + + #endregion +} diff --git a/MCPForUnity/Editor/Hooks/HookRegistry.cs b/MCPForUnity/Editor/Hooks/HookRegistry.cs new file mode 100644 index 000000000..327969df5 --- /dev/null +++ b/MCPForUnity/Editor/Hooks/HookRegistry.cs @@ -0,0 +1,269 @@ +using System; +using MCPForUnity.Editor.Hooks.EventArgs; +using MCPForUnity.Editor.Helpers; +using UnityEngine; +using UnityEngine.SceneManagement; + +namespace MCPForUnity.Editor.Hooks +{ + /// + /// Built-in hook system providing subscription points for all common Unity editor events. + /// Other systems can subscribe to these events without directly monitoring Unity callbacks. + /// + /// Event Design: + /// - Simple events: Use for basic notifications (backward compatible) + /// - Detailed events: Include additional context via Args classes (defined in HookEventArgs.cs) + /// + /// Usage: + /// + /// // Simple subscription + /// HookRegistry.OnSceneOpened += (scene) => Debug.Log(scene.name); + /// + /// // Detailed subscription with extra data + /// HookRegistry.OnSceneOpenedDetailed += (scene, args) => Debug.Log($"{scene.name} - {args.Mode}"); + /// + /// + public static class HookRegistry + { + #region Compilation Events + + public static event Action OnScriptCompiled; + public static event Action OnScriptCompiledDetailed; + public static event Action OnScriptCompilationFailed; + public static event Action OnScriptCompilationFailedDetailed; + + #endregion + + #region Scene Events + + public static event Action OnSceneSaved; + public static event Action OnSceneOpened; + public static event Action OnSceneOpenedDetailed; + public static event Action OnNewSceneCreated; + public static event Action OnNewSceneCreatedDetailed; + public static event Action OnSceneLoaded; + public static event Action OnSceneUnloaded; + + #endregion + + #region Play Mode Events + + public static event Action OnPlayModeChanged; + + #endregion + + #region Hierarchy Events + + public static event Action OnHierarchyChanged; + public static event Action OnGameObjectCreated; + public static event Action OnGameObjectDestroyed; + public static event Action OnGameObjectDestroyedDetailed; + + #endregion + + #region Selection Events + + public static event Action OnSelectionChanged; + + #endregion + + #region Project Events + + public static event Action OnProjectChanged; + public static event Action OnAssetImported; + public static event Action OnAssetDeleted; + + #endregion + + #region Build Events + + public static event Action OnBuildCompleted; + public static event Action OnBuildCompletedDetailed; + + #endregion + + #region Editor State Events + + public static event Action OnEditorUpdate; + public static event Action OnEditorIdle; + + #endregion + + #region Component Events + + public static event Action OnComponentAdded; + public static event Action OnComponentRemoved; + public static event Action OnComponentRemovedDetailed; + + #endregion + + #region Internal Notification API + + /// + /// Generic helper to safely invoke event handlers with exception handling. + /// Prevents subscriber errors from breaking the invocation chain. + /// Uses dynamic dispatch to handle different delegate signatures. + /// + private static void InvokeSafely(TDelegate handler, string eventName, Action invoke) + where TDelegate : class + { + if (handler == null) return; + + // Cast to Delegate base type to access GetInvocationList() + var multicastDelegate = handler as Delegate; + if (multicastDelegate == null) return; + + foreach (Delegate subscriber in multicastDelegate.GetInvocationList()) + { + try + { + invoke(subscriber); + } + catch (Exception ex) + { + McpLog.Warn($"[HookRegistry] {eventName} subscriber threw exception: {ex.Message}"); + } + } + } + + // P1 Fix: Exception handling - prevent subscriber errors from breaking the invocation chain + // This ensures that a misbehaving subscriber doesn't prevent other subscribers from receiving notifications + internal static void NotifyScriptCompiled() + { + InvokeSafely(OnScriptCompiled, "OnScriptCompiled", h => h()); + } + + internal static void NotifyScriptCompiledDetailed(ScriptCompilationArgs args) + { + InvokeSafely(OnScriptCompiledDetailed, "OnScriptCompiledDetailed", h => h(args)); + } + + internal static void NotifyScriptCompilationFailed(int errorCount) + { + InvokeSafely(OnScriptCompilationFailed, "OnScriptCompilationFailed", h => h(errorCount)); + } + + internal static void NotifyScriptCompilationFailedDetailed(ScriptCompilationFailedArgs args) + { + InvokeSafely(OnScriptCompilationFailedDetailed, "OnScriptCompilationFailedDetailed", h => h(args)); + } + + // Apply same exception handling pattern to other notification methods + internal static void NotifySceneSaved(Scene scene) + { + InvokeSafely(OnSceneSaved, "OnSceneSaved", h => h(scene)); + } + + internal static void NotifySceneOpened(Scene scene) + { + InvokeSafely(OnSceneOpened, "OnSceneOpened", h => h(scene)); + } + + internal static void NotifySceneOpenedDetailed(Scene scene, SceneOpenArgs args) + { + InvokeSafely(OnSceneOpenedDetailed, "OnSceneOpenedDetailed", h => h(scene, args)); + } + + internal static void NotifyNewSceneCreated(Scene scene) + { + InvokeSafely(OnNewSceneCreated, "OnNewSceneCreated", h => h(scene)); + } + + internal static void NotifyNewSceneCreatedDetailed(Scene scene, NewSceneArgs args) + { + InvokeSafely(OnNewSceneCreatedDetailed, "OnNewSceneCreatedDetailed", h => h(scene, args)); + } + + internal static void NotifySceneLoaded(Scene scene) + { + InvokeSafely(OnSceneLoaded, "OnSceneLoaded", h => h(scene)); + } + + internal static void NotifySceneUnloaded(Scene scene) + { + InvokeSafely(OnSceneUnloaded, "OnSceneUnloaded", h => h(scene)); + } + + internal static void NotifyPlayModeChanged(bool isPlaying) + { + InvokeSafely(OnPlayModeChanged, "OnPlayModeChanged", h => h(isPlaying)); + } + + internal static void NotifyHierarchyChanged() + { + InvokeSafely(OnHierarchyChanged, "OnHierarchyChanged", h => h()); + } + + internal static void NotifyGameObjectCreated(GameObject gameObject) + { + InvokeSafely(OnGameObjectCreated, "OnGameObjectCreated", h => h(gameObject)); + } + + internal static void NotifyGameObjectDestroyed(GameObject gameObject) + { + InvokeSafely(OnGameObjectDestroyed, "OnGameObjectDestroyed", h => h(gameObject)); + } + + internal static void NotifyGameObjectDestroyedDetailed(GameObjectDestroyedArgs args) + { + InvokeSafely(OnGameObjectDestroyedDetailed, "OnGameObjectDestroyedDetailed", h => h(args)); + } + + internal static void NotifySelectionChanged(GameObject gameObject) + { + InvokeSafely(OnSelectionChanged, "OnSelectionChanged", h => h(gameObject)); + } + + internal static void NotifyProjectChanged() + { + InvokeSafely(OnProjectChanged, "OnProjectChanged", h => h()); + } + + internal static void NotifyAssetImported() + { + InvokeSafely(OnAssetImported, "OnAssetImported", h => h()); + } + + internal static void NotifyAssetDeleted() + { + InvokeSafely(OnAssetDeleted, "OnAssetDeleted", h => h()); + } + + internal static void NotifyBuildCompleted(bool success) + { + InvokeSafely(OnBuildCompleted, "OnBuildCompleted", h => h(success)); + } + + internal static void NotifyBuildCompletedDetailed(BuildArgs args) + { + InvokeSafely(OnBuildCompletedDetailed, "OnBuildCompletedDetailed", h => h(args)); + } + + internal static void NotifyEditorUpdate() + { + InvokeSafely(OnEditorUpdate, "OnEditorUpdate", h => h()); + } + + internal static void NotifyEditorIdle() + { + InvokeSafely(OnEditorIdle, "OnEditorIdle", h => h()); + } + + internal static void NotifyComponentAdded(Component component) + { + InvokeSafely(OnComponentAdded, "OnComponentAdded", h => h(component)); + } + + internal static void NotifyComponentRemoved(Component component) + { + InvokeSafely(OnComponentRemoved, "OnComponentRemoved", h => h(component)); + } + + internal static void NotifyComponentRemovedDetailed(ComponentRemovedArgs args) + { + InvokeSafely(OnComponentRemovedDetailed, "OnComponentRemovedDetailed", h => h(args)); + } + + #endregion + } +} diff --git a/MCPForUnity/Editor/Hooks/HookTest.cs b/MCPForUnity/Editor/Hooks/HookTest.cs new file mode 100644 index 000000000..bb405f720 --- /dev/null +++ b/MCPForUnity/Editor/Hooks/HookTest.cs @@ -0,0 +1,84 @@ +// using UnityEngine; +// using UnityEditor; +// using UnityEngine.SceneManagement; + +// namespace MCPForUnity.Editor.ActionTrace.Sources +// { +// /// +// /// Test script to verify HookRegistry events are firing correctly. +// /// Check the Unity Console for output when interacting with the editor. +// /// +// [InitializeOnLoad] +// public static class HookTest +// { +// static HookTest() +// { +// // Subscribe to hook events for testing +// HookRegistry.OnScriptCompiled += OnScriptCompiled; +// HookRegistry.OnScriptCompilationFailed += OnScriptCompilationFailed; +// HookRegistry.OnSceneSaved += OnSceneSaved; +// HookRegistry.OnSceneOpened += OnSceneOpened; +// HookRegistry.OnPlayModeChanged += OnPlayModeChanged; +// HookRegistry.OnSelectionChanged += OnSelectionChanged; +// HookRegistry.OnHierarchyChanged += OnHierarchyChanged; +// HookRegistry.OnGameObjectCreated += OnGameObjectCreated; +// HookRegistry.OnGameObjectDestroyed += OnGameObjectDestroyed; +// HookRegistry.OnComponentAdded += OnComponentAdded; + +// Debug.Log("[HookTest] HookRegistry test initialized. Events are being monitored."); +// } + +// private static void OnScriptCompiled() +// { +// Debug.Log("[HookTest] ✅ ScriptCompiled event fired!"); +// } + +// private static void OnScriptCompilationFailed(int errorCount) +// { +// Debug.Log($"[HookTest] ❌ ScriptCompilationFailed event fired! Errors: {errorCount}"); +// } + +// private static void OnSceneSaved(Scene scene) +// { +// Debug.Log($"[HookTest] 💾 SceneSaved event fired: {scene.name}"); +// } + +// private static void OnSceneOpened(Scene scene) +// { +// Debug.Log($"[HookTest] 📂 SceneOpened event fired: {scene.name}"); +// } + +// private static void OnPlayModeChanged(bool isPlaying) +// { +// Debug.Log($"[HookTest] ▶️ PlayModeChanged event fired: isPlaying={isPlaying}"); +// } + +// private static void OnSelectionChanged(GameObject selectedGo) +// { +// string name = selectedGo != null ? selectedGo.name : "null"; +// Debug.Log($"[HookTest] 🔍 SelectionChanged event fired: {name}"); +// } + +// private static void OnHierarchyChanged() +// { +// Debug.Log("[HookTest] 🏗️ HierarchyChanged event fired"); +// } + +// private static void OnGameObjectCreated(GameObject go) +// { +// if (go != null) +// Debug.Log($"[HookTest] 🎮 GameObjectCreated event fired: {go.name}"); +// } + +// private static void OnGameObjectDestroyed(GameObject go) +// { +// Debug.Log("[HookTest] 🗑️ GameObjectDestroyed event fired"); +// } + +// private static void OnComponentAdded(Component component) +// { +// if (component != null) +// Debug.Log($"[HookTest] 🔧 ComponentAdded event fired: {component.GetType().Name} to {component.gameObject.name}"); +// } +// } +// } diff --git a/MCPForUnity/Editor/Hooks/IGameObjectCacheProvider.cs b/MCPForUnity/Editor/Hooks/IGameObjectCacheProvider.cs new file mode 100644 index 000000000..e33ed2560 --- /dev/null +++ b/MCPForUnity/Editor/Hooks/IGameObjectCacheProvider.cs @@ -0,0 +1,54 @@ +namespace MCPForUnity.Editor.Hooks +{ + /// + /// Interface for providing cached GameObject data. + /// Used by UnityEventHooks to decouple from ActionTrace-specific implementations. + /// Implementations can provide cached names and GlobalIDs for destroyed GameObjects. + /// + public interface IGameObjectCacheProvider + { + /// + /// Get the cached name for a GameObject by its InstanceID. + /// Returns null if the GameObject is not in the cache. + /// + /// The InstanceID of the GameObject + /// The cached name, or null if not found + string GetCachedName(int instanceId); + + /// + /// Get the cached GlobalID for a GameObject by its InstanceID. + /// Returns null if the GameObject is not in the cache. + /// + /// The InstanceID of the GameObject + /// The cached GlobalID, or null if not found + string GetCachedGlobalId(int instanceId); + + /// + /// Register a GameObject for tracking. + /// Called when a GameObject is selected or has a component added. + /// + /// The GameObject to register + void RegisterGameObject(UnityEngine.GameObject gameObject); + + /// + /// Detect and report GameObject changes (created and destroyed objects). + /// + /// Callback for newly created GameObjects + /// Callback for destroyed GameObjects with InstanceID + void DetectChanges( + System.Action onCreated, + System.Action onDestroyed); + + /// + /// Initialize the tracking system. + /// Should be called once when the editor loads. + /// + void InitializeTracking(); + + /// + /// Reset all tracking state. + /// Called when scenes change or on domain reload. + /// + void Reset(); + } +} diff --git a/MCPForUnity/Editor/Hooks/UnityEventHooks/UnityEventHooks.Advanced.cs b/MCPForUnity/Editor/Hooks/UnityEventHooks/UnityEventHooks.Advanced.cs new file mode 100644 index 000000000..7c8f2b2d1 --- /dev/null +++ b/MCPForUnity/Editor/Hooks/UnityEventHooks/UnityEventHooks.Advanced.cs @@ -0,0 +1,282 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MCPForUnity.Editor.Hooks.EventArgs; +using UnityEditor; +using UnityEditor.Build.Reporting; +using UnityEngine; + +namespace MCPForUnity.Editor.Hooks +{ + /// + /// Advanced tracking features for UnityEventHooks. + /// Implements script compilation tracking, GameObject change detection, + /// and component removal tracking using the IGameObjectCacheProvider interface. + /// + /// This file uses dependency injection via IGameObjectCacheProvider to decouple + /// from ActionTrace-specific implementations, allowing UnityEventHooks to remain + /// general infrastructure in the Hooks/ folder. + /// + public static partial class UnityEventHooks + { + #region Cache Provider + + private static IGameObjectCacheProvider _cacheProvider; + + /// + /// Set the GameObject cache provider. + /// Called by ActionTrace during initialization to inject tracking capability. + /// + public static void SetGameObjectCacheProvider(IGameObjectCacheProvider provider) + { + _cacheProvider = provider; + } + + #endregion + + #region Script Compilation State + + private static DateTime _compileStartTime; + private static bool _isCompiling; + + #endregion + + #region Build State + + private static DateTime _buildStartTime; + private static string _currentBuildPlatform; + + #endregion + + #region Component Removal Tracking State + + // GameObject InstanceID -> Dictionary + private static readonly Dictionary> _gameObjectComponentCache = new(); + + #endregion + + #region Partial Method Implementations + + static partial void InitializeTracking() + { + _cacheProvider?.InitializeTracking(); + } + + static partial void ResetTracking() + { + _cacheProvider?.Reset(); + _gameObjectComponentCache.Clear(); + } + + static partial void RegisterGameObjectForTracking(GameObject gameObject) + { + if (gameObject == null) return; + + // Register with cache provider for GameObject tracking + _cacheProvider?.RegisterGameObject(gameObject); + + // Register locally for component removal tracking + int goId = gameObject.GetInstanceID(); + var componentMap = new Dictionary(); + + foreach (var comp in gameObject.GetComponents()) + { + if (comp != null) componentMap[comp.GetInstanceID()] = comp.GetType().Name; + } + + _gameObjectComponentCache[goId] = componentMap; + } + + static partial void TrackScriptCompilation() + { + bool isNowCompiling = EditorApplication.isCompiling; + + if (isNowCompiling && !_isCompiling) + { + _compileStartTime = DateTime.UtcNow; + _isCompiling = true; + } + else if (!isNowCompiling && _isCompiling) + { + _isCompiling = false; + + var duration = DateTime.UtcNow - _compileStartTime; + int scriptCount = CountScripts(); + int errorCount = GetCompilationErrorCount(); + + if (errorCount > 0) + { + HookRegistry.NotifyScriptCompilationFailed(errorCount); + HookRegistry.NotifyScriptCompilationFailedDetailed(new ScriptCompilationFailedArgs + { + ScriptCount = scriptCount, + DurationMs = (long)duration.TotalMilliseconds, + ErrorCount = errorCount + }); + } + else + { + HookRegistry.NotifyScriptCompiled(); + HookRegistry.NotifyScriptCompiledDetailed(new ScriptCompilationArgs + { + ScriptCount = scriptCount, + DurationMs = (long)duration.TotalMilliseconds + }); + } + } + } + + static partial void TrackGameObjectChanges() + { + _cacheProvider?.DetectChanges( + onCreated: (go) => + { + HookRegistry.NotifyGameObjectCreated(go); + }, + onDestroyed: (instanceId) => + { + HookRegistry.NotifyGameObjectDestroyed(null); + + // Get cached data for detailed event + string name = _cacheProvider?.GetCachedName(instanceId) ?? "Unknown"; + string globalId = _cacheProvider?.GetCachedGlobalId(instanceId) ?? $"Instance:{instanceId}"; + + HookRegistry.NotifyGameObjectDestroyedDetailed(new GameObjectDestroyedArgs + { + InstanceId = instanceId, + Name = name, + GlobalId = globalId + }); + } + ); + } + + static partial void TrackComponentRemoval() + { + if (_gameObjectComponentCache.Count == 0) return; + + var trackedIds = _gameObjectComponentCache.Keys.ToList(); + var toRemove = new List(); + + foreach (int goId in trackedIds) + { + var go = EditorUtility.InstanceIDToObject(goId) as GameObject; + + if (go == null) + { + toRemove.Add(goId); + continue; + } + + var currentComponents = go.GetComponents(); + var currentIds = new HashSet(); + + foreach (var comp in currentComponents) + { + if (comp != null) currentIds.Add(comp.GetInstanceID()); + } + + var cachedMap = _gameObjectComponentCache[goId]; + var removedIds = cachedMap.Keys.Except(currentIds).ToList(); + + foreach (int removedId in removedIds) + { + string componentType = cachedMap[removedId]; + HookRegistry.NotifyComponentRemovedDetailed(new ComponentRemovedArgs + { + Owner = go, + ComponentInstanceId = removedId, + ComponentType = componentType + }); + } + + if (removedIds.Count > 0 || currentIds.Count != cachedMap.Count) + { + RegisterGameObjectForTracking(go); + } + } + + foreach (int id in toRemove) + { + _gameObjectComponentCache.Remove(id); + } + } + + static partial void BuildPlayerHandler(BuildPlayerOptions options) + { + _buildStartTime = DateTime.UtcNow; + _currentBuildPlatform = GetBuildTargetName(options.target); + + BuildReport result = BuildPipeline.BuildPlayer(options); + + var duration = DateTime.UtcNow - _buildStartTime; + bool success = result.summary.result == BuildResult.Succeeded; + + HookRegistry.NotifyBuildCompleted(success); + HookRegistry.NotifyBuildCompletedDetailed(new BuildArgs + { + Platform = _currentBuildPlatform, + Location = options.locationPathName, + DurationMs = (long)duration.TotalMilliseconds, + SizeBytes = success ? result.summary.totalSize : null, + Success = success, + Summary = success ? null : result.summary.ToString() + }); + + _currentBuildPlatform = null; + } + + #endregion + + #region Helper Methods + + private static int CountScripts() + { + try { return AssetDatabase.FindAssets("t:Script").Length; } + catch { return 0; } + } + + private static int GetCompilationErrorCount() + { + try + { + var assembly = typeof(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; } + } + + private static string GetBuildTargetName(BuildTarget target) + { + try + { + var assembly = typeof(HookRegistry).Assembly; + var type = assembly.GetType("MCPForUnity.Editor.Helpers.BuildTargetUtility"); + if (type != null) + { + var method = type.GetMethod("GetBuildTargetName", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public); + if (method != null) + { + var result = method.Invoke(null, new object[] { target }); + if (result is string name) return name; + } + } + } + catch { } + + return target.ToString(); + } + + #endregion + } +} diff --git a/MCPForUnity/Editor/Hooks/UnityEventHooks/UnityEventHooks.cs b/MCPForUnity/Editor/Hooks/UnityEventHooks/UnityEventHooks.cs new file mode 100644 index 000000000..c0126f6e2 --- /dev/null +++ b/MCPForUnity/Editor/Hooks/UnityEventHooks/UnityEventHooks.cs @@ -0,0 +1,294 @@ +using System; +using MCPForUnity.Editor.Hooks.EventArgs; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.SceneManagement; + +namespace MCPForUnity.Editor.Hooks +{ + /// + /// Pure event detector for Unity editor events. + /// Detects Unity callbacks and notifies HookRegistry for other systems to subscribe. + /// + /// Architecture: + /// Unity Events → UnityEventHooks (detection) → HookRegistry → Subscribers + /// + /// You should use HookRegistry to subscribe to events, not UnityEventHooks directly. + /// + /// Hook Coverage: + /// - Component events: ComponentAdded + /// - GameObject events: GameObjectCreated, GameObjectDestroyed + /// - Hierarchy events: HierarchyChanged + /// - Selection events: SelectionChanged + /// - Play mode events: PlayModeChanged + /// - Scene events: SceneSaved, SceneOpened, SceneLoaded, SceneUnloaded, NewSceneCreated + /// - Script events: ScriptCompiled, ScriptCompilationFailed + /// - Build events: BuildCompleted + /// - Editor events: EditorUpdate + /// + [InitializeOnLoad] + public static partial class UnityEventHooks + { + #region Hierarchy State + + private static DateTime _lastHierarchyChange; + private static readonly object _lock = new(); + private static bool _isInitialized; + + #endregion + + static UnityEventHooks() + { + // Subscribe to cleanup events first + AssemblyReloadEvents.beforeAssemblyReload -= OnBeforeAssemblyReload; + AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload; + EditorApplication.quitting -= OnEditorQuitting; + EditorApplication.quitting += OnEditorQuitting; + + // Only initialize subscriptions once + if (!_isInitialized) + { + SubscribeToUnityEvents(); + _isInitialized = true; + } + } + + /// + /// Subscribe to all Unity events. + /// + private static void SubscribeToUnityEvents() + { + // GameObject/Component Events + ObjectFactory.componentWasAdded += OnComponentAdded; + + // Hierarchy Events + EditorApplication.hierarchyChanged += OnHierarchyChanged; + + // Selection Events + Selection.selectionChanged += OnSelectionChanged; + + // Play Mode Events + EditorApplication.playModeStateChanged += OnPlayModeStateChanged; + + // Scene Events + EditorSceneManager.sceneSaved += OnSceneSaved; + EditorSceneManager.sceneOpened += OnSceneOpened; + EditorSceneManager.sceneLoaded += OnSceneLoaded; + EditorSceneManager.sceneUnloaded += OnSceneUnloaded; + EditorSceneManager.newSceneCreated += OnNewSceneCreated; + + // Build Events + BuildPlayerWindow.RegisterBuildPlayerHandler(options => BuildPlayerHandler(options)); + + // Editor Update + EditorApplication.update += OnUpdate; + + // Initialize tracking (one-time delayCall is safe) + EditorApplication.delayCall += () => InitializeTracking(); + } + + /// + /// Unsubscribe from all Unity events. + /// Called before domain reload and when editor quits. + /// + private static void UnsubscribeFromUnityEvents() + { + // GameObject/Component Events + ObjectFactory.componentWasAdded -= OnComponentAdded; + + // Hierarchy Events + EditorApplication.hierarchyChanged -= OnHierarchyChanged; + + // Selection Events + Selection.selectionChanged -= OnSelectionChanged; + + // Play Mode Events + EditorApplication.playModeStateChanged -= OnPlayModeStateChanged; + + // Scene Events + EditorSceneManager.sceneSaved -= OnSceneSaved; + EditorSceneManager.sceneOpened -= OnSceneOpened; + EditorSceneManager.sceneLoaded -= OnSceneLoaded; + EditorSceneManager.sceneUnloaded -= OnSceneUnloaded; + EditorSceneManager.newSceneCreated -= OnNewSceneCreated; + + // Editor Update + EditorApplication.update -= OnUpdate; + + // Note: BuildPlayerHandler doesn't have an unregister API + } + + /// + /// Called before assembly reload (domain reload). + /// Unsubscribes from all Unity events to prevent memory leaks. + /// + private static void OnBeforeAssemblyReload() + { + UnsubscribeFromUnityEvents(); + ResetTracking(); + _isInitialized = false; + } + + /// + /// Called when Unity editor is quitting. + /// Unsubscribes from all Unity events to ensure clean shutdown. + /// + private static void OnEditorQuitting() + { + UnsubscribeFromUnityEvents(); + ResetTracking(); + _isInitialized = false; + } + + #region GameObject/Component Events + + private static void OnComponentAdded(Component component) + { + if (component == null) return; + HookRegistry.NotifyComponentAdded(component); + + var gameObject = component.gameObject; + if (gameObject != null) RegisterGameObjectForTracking(gameObject); + } + + #endregion + + #region Hierarchy Events + + 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; + } + + HookRegistry.NotifyHierarchyChanged(); + TrackComponentRemoval(); + } + + #endregion + + #region Selection Events + + private static void OnSelectionChanged() + { + GameObject selectedGo = Selection.activeObject as GameObject; + HookRegistry.NotifySelectionChanged(selectedGo); + + if (selectedGo != null) RegisterGameObjectForTracking(selectedGo); + } + + #endregion + + #region Play Mode Events + + private static void OnPlayModeStateChanged(PlayModeStateChange state) + { + switch (state) + { + case PlayModeStateChange.EnteredPlayMode: + HookRegistry.NotifyPlayModeChanged(true); + break; + case PlayModeStateChange.EnteredEditMode: + HookRegistry.NotifyPlayModeChanged(false); + break; + } + } + + #endregion + + #region Scene Events + + private static void OnSceneSaved(Scene scene) => HookRegistry.NotifySceneSaved(scene); + + private static void OnSceneOpened(Scene scene, OpenSceneMode mode) + { + HookRegistry.NotifySceneOpened(scene); + HookRegistry.NotifySceneOpenedDetailed(scene, new SceneOpenArgs { Mode = mode }); + } + + private static void OnNewSceneCreated(Scene scene, NewSceneSetup setup, NewSceneMode mode) + { + HookRegistry.NotifyNewSceneCreated(scene); + HookRegistry.NotifyNewSceneCreatedDetailed(scene, new NewSceneArgs { Setup = setup, Mode = mode }); + } + + private static void OnSceneLoaded(Scene scene, LoadSceneMode mode) + { + HookRegistry.NotifySceneLoaded(scene); + ResetTracking(); + InitializeTracking(); + } + + private static void OnSceneUnloaded(Scene scene) + { + HookRegistry.NotifySceneUnloaded(scene); + ResetTracking(); + } + + #endregion + + #region Editor Update Events + + private static void OnUpdate() + { + if (EditorApplication.isPlayingOrWillChangePlaymode) return; + + HookRegistry.NotifyEditorUpdate(); + TrackScriptCompilation(); + TrackGameObjectChanges(); + } + + #endregion + + #region Tracking Extension Points (for Advanced features) + + /// + /// Extension point for tracking initialization. + /// Override in Advanced partial class to provide custom tracking. + /// + static partial void InitializeTracking(); + + /// + /// Extension point for tracking reset. + /// Override in Advanced partial class to provide custom tracking. + /// + static partial void ResetTracking(); + + /// + /// Extension point for GameObject registration. + /// Called when a GameObject is selected or has a component added. + /// + static partial void RegisterGameObjectForTracking(GameObject gameObject); + + /// + /// Extension point for script compilation tracking. + /// Override in Advanced partial class to detect compilation state changes. + /// + static partial void TrackScriptCompilation(); + + /// + /// Extension point for GameObject change tracking. + /// Override in Advanced partial class to detect created/destroyed GameObjects. + /// + static partial void TrackGameObjectChanges(); + + /// + /// Extension point for component removal tracking. + /// Override in Advanced partial class to detect removed components. + /// + static partial void TrackComponentRemoval(); + + /// + /// Extension point for build player handling. + /// Override in Advanced partial class to handle build completion. + /// + static partial void BuildPlayerHandler(BuildPlayerOptions options); + + #endregion + } +} diff --git a/MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs b/MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs index c280d9559..45f5b91cb 100644 --- a/MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs +++ b/MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs @@ -1,3 +1,4 @@ +using MCPForUnity.Editor.ActionTrace.UI.Windows; using MCPForUnity.Editor.Setup; using MCPForUnity.Editor.Windows; using UnityEditor; @@ -32,5 +33,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..c8720b168 --- /dev/null +++ b/MCPForUnity/Editor/Resources/ActionTrace/ActionTraceViewResource.cs @@ -0,0 +1,488 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.ActionTrace.Context; +using MCPForUnity.Editor.ActionTrace.Core.Settings; +using MCPForUnity.Editor.ActionTrace.Core.Store; +using MCPForUnity.Editor.ActionTrace.Analysis.Query; +using MCPForUnity.Editor.ActionTrace.Analysis.Summarization; +using Newtonsoft.Json.Linq; +using static MCPForUnity.Editor.ActionTrace.Analysis.Query.ActionTraceQuery; +using MCPForUnity.Editor.ActionTrace.Core.Models; +using MCPForUnity.Editor.ActionTrace.Semantics; +using MCPForUnity.Editor.ActionTrace.Core; + +namespace MCPForUnity.Editor.Resources.ActionTrace +{ + /// + /// Response wrapper constants for ActionTraceView. + /// Simplified schema: Basic, WithSemantics, Aggregated. + /// + internal static class ResponseSchemas + { + public const string Basic = "action_trace_view@1"; + public const string WithSemantics = "action_trace_view@2"; + public const string Aggregated = "action_trace_view@3"; + } + + /// + /// 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_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+) + /// - summary_only: If true, return aggregated transactions instead of raw events (default: false) + /// - task_id: Filter events by task ID (for AINote events) + /// - conversation_id: Filter events by conversation ID (for AINote events) + /// + /// 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 includeSemantics = GetIncludeSemantics(@params); + + // L3 Semantic Whitelist: Parse minimum importance threshold + float minImportance = GetMinImportance(@params); + + // Task-level filtering: Parse task_id and conversation_id + 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 + if (includeSemantics) + { + return QueryWithSemanticsOnly(limit, sinceSequence, minImportance, taskId, conversationId); + } + + // Basic query without 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 semantics. + /// Applies L3 importance filter by default (medium+ importance). + /// Supports task_id and conversation_id filtering. + /// Includes target_instance_id and target_name for each event. + /// + 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(); + + // Apply task-level filtering + filteredEvents = ApplyTaskFilters(filteredEvents, taskId, conversationId); + + // Build instance info cache for performance (many events may reference the same object) + var instanceInfoCache = new Dictionary(); + + var eventItems = filteredEvents.Select(e => + { + // Get or compute instance info (with caching) + if (!instanceInfoCache.TryGetValue(e.TargetId, out var info)) + { + info = GlobalIdHelper.GetInstanceInfo(e.TargetId); + instanceInfoCache[e.TargetId] = info; + } + + return new + { + sequence = e.Sequence, + timestamp_unix_ms = e.TimestampUnixMs, + type = e.Type, + target_instance_id = info.instanceId, + target_name = info.displayName, + 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. + /// Applies L3 importance filter by default. + /// Supports task_id and conversation_id filtering. + /// Includes target_instance_id and target_name for each event. + /// + 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(); + + // Apply task-level filtering + filtered = ApplyTaskFiltersToProjected(filtered, taskId, conversationId); + + // Build instance info cache for performance (many events may reference the same object) + var instanceInfoCache = new Dictionary(); + + var eventItems = filtered.Select(p => + { + // Get or compute instance info (with caching) + if (!instanceInfoCache.TryGetValue(p.Event.TargetId, out var info)) + { + info = GlobalIdHelper.GetInstanceInfo(p.Event.TargetId); + instanceInfoCache[p.Event.TargetId] = info; + } + + return new + { + sequence = p.Event.Sequence, + timestamp_unix_ms = p.Event.TimestampUnixMs, + type = p.Event.Type, + target_instance_id = info.instanceId, + target_name = info.displayName, + 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 transaction aggregation. + /// + /// Returns AtomicOperation list instead of raw events. + /// Reduces token consumption by grouping related events. + /// Supports task_id and conversation_id filtering. + /// + 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 + filteredEvents = ApplyTaskFilters(filteredEvents, taskId, conversationId); + + // Step 5: Aggregate into transactions + var operations = TransactionAggregator.Aggregate(filteredEvents); + + // Step 6: 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 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.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; + } + } +} diff --git a/MCPForUnity/Editor/Tools/ActionTraceSettingsTool.cs b/MCPForUnity/Editor/Tools/ActionTraceSettingsTool.cs new file mode 100644 index 000000000..f02a66e9d --- /dev/null +++ b/MCPForUnity/Editor/Tools/ActionTraceSettingsTool.cs @@ -0,0 +1,65 @@ +using System; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.ActionTrace.Core.Settings; +using MCPForUnity.Editor.ActionTrace.Core.Store; +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 side 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/AddActionTraceNoteTool.cs b/MCPForUnity/Editor/Tools/AddActionTraceNoteTool.cs new file mode 100644 index 000000000..93dafaee1 --- /dev/null +++ b/MCPForUnity/Editor/Tools/AddActionTraceNoteTool.cs @@ -0,0 +1,191 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json.Linq; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.ActionTrace.Core.Settings; +using MCPForUnity.Editor.ActionTrace.Core.Store; +using MCPForUnity.Editor.ActionTrace.Core.Models; + +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": "Completed player movement system refactor, speed increased from 5 to 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 + { + /// + /// Helper to normalize parameter names from snake_case to camelCase. + /// Supports both legacy snake_case (from direct tool calls) and camelCase (from batch_execute normalization). + /// + private static string GetParamValue(JToken @params, string camelCaseName, string defaultValue = null) + { + // Try camelCase first (normalized by batch_execute) + var value = @params[camelCaseName]; + if (value != null) return value.ToString(); + + // Fallback to snake_case (legacy format) + string snakeCase = string.Concat(camelCaseName.Select((c, i) => i > 0 && char.IsUpper(c) ? "_" + c.ToString().ToLower() : c.ToString().ToLower())); + value = @params[snakeCase]; + return value?.ToString() ?? defaultValue; + } + + /// + /// 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."); + } + + // Use helper to normalize parameter names (supports both snake_case and camelCase) + string agentId = GetParamValue(@params, "agentId", "unknown"); + string taskId = GetParamValue(@params, "taskId"); + string conversationId = GetParamValue(@params, "conversationId"); + string intent = GetParamValue(@params, "intent"); + string agentModel = GetParamValue(@params, "agentModel"); + + // 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 + if (!string.IsNullOrEmpty(intent)) + { + payload["intent"] = intent; + } + + if (!string.IsNullOrEmpty(agentModel)) + { + payload["agent_model"] = agentModel; + } + + // 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/GetActionTraceSummaryTool.cs b/MCPForUnity/Editor/Tools/GetActionTraceSummaryTool.cs new file mode 100644 index 000000000..350c98f7a --- /dev/null +++ b/MCPForUnity/Editor/Tools/GetActionTraceSummaryTool.cs @@ -0,0 +1,456 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Resources.ActionTrace; +using MCPForUnity.Editor.ActionTrace.Core.Store; +using MCPForUnity.Editor.ActionTrace.Core.Models; +using MCPForUnity.Editor.ActionTrace.Semantics; +using Newtonsoft.Json.Linq; +using MCPForUnity.Editor.ActionTrace.Core; + +namespace MCPForUnity.Editor.Tools +{ + /// + /// MCP tool for generating AI-friendly summaries of ActionTrace events. + /// + /// This is a "compressed view" tool designed for Agentic Workflow. + /// Instead of returning hundreds of individual events, it returns: + /// - Structured aggregates (counts by type, target, category) + /// - Textual summary (human-readable description) + /// - Warnings (detected anomalies like excessive modifications) + /// - Suggested actions (AI can use these to decide next steps) + /// + /// Python wrapper: Server/src/services/tools/get_action_trace_summary.py + /// + [McpForUnityTool("get_action_trace_summary", + Description = "Get AI-friendly summary of recent ActionTrace events. Returns categorized changes, warnings, and suggested actions to reduce token usage and improve context understanding.")] + public static class GetActionTraceSummaryTool + { + /// + /// Parameters for get_action_trace_summary tool. + /// + public class Parameters + { + /// + /// Time window for the summary: '5m', '15m', '1h', 'today' + /// + [ToolParameter("Time window: '5m', '15m', '1h', 'today' (default: '1h')", Required = false, DefaultValue = "1h")] + public string TimeRange { get; set; } = "1h"; + + /// + /// Maximum number of events to analyze for the summary (default: 200) + /// + [ToolParameter("Maximum events to analyze (1-500, default: 200)", Required = false, DefaultValue = "200")] + public int Limit { get; set; } = 200; + + /// + /// Filter by task ID (only show events associated with this task) + /// + [ToolParameter("Filter by task ID (for multi-agent scenarios)", Required = false)] + public string TaskId { get; set; } + + /// + /// Filter by conversation ID + /// + [ToolParameter("Filter by conversation ID", Required = false)] + public string ConversationId { get; set; } + + /// + /// Minimum importance level (low/medium/high/critical) + /// + [ToolParameter("Minimum importance level (low/medium/high/critical)", Required = false, DefaultValue = "low")] + public string MinImportance { get; set; } = "low"; + } + + /// + /// Main handler for generating action trace summaries. + /// + public static object HandleCommand(JObject @params) + { + try + { + // Parse parameters + string timeRange = GetTimeRange(@params); + int limit = GetLimit(@params); + string taskId = GetTaskId(@params); + string conversationId = GetConversationId(@params); + float minImportance = GetMinImportance(@params); + + // Calculate time threshold + long? sinceSequence = CalculateSinceSequence(timeRange); + + // Query events + var events = EventStore.Query(limit, sinceSequence); + + // Apply disabled types filter + events = ApplyDisabledTypesFilter(events); + + // Apply importance filter + var scorer = new DefaultEventScorer(); + var filteredEvents = events + .Where(e => scorer.Score(e) >= minImportance) + .ToList(); + + // Apply task-level filtering + filteredEvents = ApplyTaskFilters(filteredEvents, taskId, conversationId); + + if (filteredEvents.Count == 0) + { + return new SuccessResponse("No events found for the specified criteria.", new + { + time_range = timeRange, + summary = "No significant activity detected in the specified time range.", + categories = new + { + total_count = 0, + by_type = new Dictionary(), + by_importance = new Dictionary() + }, + warnings = Array.Empty(), + suggested_actions = Array.Empty() + }); + } + + // Generate summary + var summary = GenerateSummary(filteredEvents, timeRange); + + return new SuccessResponse($"Generated summary for {filteredEvents.Count} events.", summary); + } + catch (Exception ex) + { + McpLog.Error($"[GetActionTraceSummaryTool] Error: {ex.Message}"); + return new ErrorResponse($"Error generating ActionTrace summary: {ex.Message}"); + } + } + + /// + /// Generate the structured summary from filtered events. + /// + private static object GenerateSummary(List events, string timeRange) + { + // Aggregates + var byType = new Dictionary(); + var byImportance = new Dictionary(); + var byTarget = new Dictionary(); + var errorEvents = new List(); + var warnings = new List(); + var suggestions = new List(); + + // Track event types for categorization + int createdCount = 0; + int deletedCount = 0; + int modifiedCount = 0; + int errorCount = 0; + + // Calculate time range from actual events + long startTimeMs = events[0].TimestampUnixMs; + long endTimeMs = events[events.Count - 1].TimestampUnixMs; + long durationMs = endTimeMs - startTimeMs; + + foreach (var evt in events) + { + // Count by type + if (!byType.ContainsKey(evt.Type)) + byType[evt.Type] = 0; + byType[evt.Type]++; + + // Count by importance (using scorer) + var scorer = new DefaultEventScorer(); + float importance = scorer.Score(evt); + string importanceCategory = GetImportanceCategory(importance); + if (!byImportance.ContainsKey(importanceCategory)) + byImportance[importanceCategory] = 0; + byImportance[importanceCategory]++; + + // Get target name + var targetInfo = GlobalIdHelper.GetInstanceInfo(evt.TargetId); + string targetName = targetInfo.displayName ?? evt.TargetId; + + // Track by target + if (!byTarget.ContainsKey(targetName)) + byTarget[targetName] = new TargetStats { Name = targetName }; + byTarget[targetName].Count++; + byTarget[targetName].Types.Add(evt.Type); + + // Categorize event + string evtTypeLower = evt.Type.ToLower(); + if (evtTypeLower.Contains("create") || evtTypeLower.Contains("add")) + createdCount++; + else if (evtTypeLower.Contains("delete") || evtTypeLower.Contains("destroy") || evtTypeLower.Contains("remove")) + deletedCount++; + else if (evtTypeLower.Contains("modify") || evtTypeLower.Contains("change") || evtTypeLower.Contains("set")) + modifiedCount++; + + // Check for errors + if (importanceCategory == "critical" || evtTypeLower.Contains("error") || evtTypeLower.Contains("exception")) + { + errorCount++; + errorEvents.Add(new + { + sequence = evt.Sequence, + type = evt.Type, + target = targetName, + summary = evt.GetSummary() + }); + } + } + + // Detect anomalies and generate warnings + var topTargets = byTarget.OrderByDescending(kv => kv.Value.Count).Take(5).ToList(); + foreach (var targetStat in topTargets) + { + // Flag excessive modifications (potential loop or thrashing) + if (targetStat.Value.Count > 20) + { + warnings.Add($"Object '{targetStat.Key}' was modified {targetStat.Value.Count} times " + + $"(potential infinite loop or rapid-fire operations)"); + } + } + + // Check for high error rate + if (errorCount > 0) + { + double errorRate = (double)errorCount / events.Count; + if (errorRate > 0.1) // More than 10% errors + { + warnings.Add($"High error rate detected: {errorCount}/{events.Count} operations failed"); + } + } + + // Generate suggested actions based on patterns + if (errorCount > 0) + suggestions.Add("Investigate compilation or runtime errors before proceeding"); + + if (createdCount > 0 && !events.Any(e => e.Type.ToLower().Contains("save"))) + { + suggestions.Add("Consider saving the scene (unsaved changes detected)"); + } + + if (deletedCount > 5) + { + suggestions.Add("Review bulk deletions (potential accidental mass delete)"); + } + + if (modifiedCount > 50) + { + suggestions.Add("Consider creating a prefab for frequently modified objects"); + } + + // Build textual summary + var summaryParts = new List(); + if (createdCount > 0) + summaryParts.Add($"created {createdCount} objects"); + if (modifiedCount > 0) + summaryParts.Add($"modified {modifiedCount} properties/objects"); + if (deletedCount > 0) + summaryParts.Add($"deleted {deletedCount} objects"); + if (errorCount > 0) + summaryParts.Add($"encountered {errorCount} error(s)"); + + string summaryText = summaryParts.Count > 0 + ? $"In the last {timeRange}, {string.Join(", ", summaryParts)}." + : $"No significant activity detected in the last {timeRange}."; + + // Build top targets summary + var topTargetsSummary = topTargets.Take(3).Select(t => new + { + name = t.Key, + count = t.Value.Count, + types = t.Value.Types.Take(5).ToList() + }).ToList(); + + return new + { + time_range = timeRange, + duration_analyzed_ms = durationMs, + summary = summaryText, + categories = new + { + total_count = events.Count, + created_count = createdCount, + modified_count = modifiedCount, + deleted_count = deletedCount, + error_count = errorCount, + by_type = byType.OrderByDescending(kv => kv.Value).Take(10).ToDictionary(kv => kv.Key, kv => kv.Value), + by_importance = byImportance + }, + top_targets = topTargetsSummary, + errors = errorEvents.Take(5).ToList(), + warnings = warnings.Take(3).ToList(), + suggested_actions = suggestions, + current_sequence = EventStore.CurrentSequence + }; + } + + /// + /// Convert importance score to category string. + /// + private static string GetImportanceCategory(float score) + { + if (score >= 0.9f) return "critical"; + if (score >= 0.7f) return "high"; + if (score >= 0.4f) return "medium"; + return "low"; + } + + /// + /// Parse time_range parameter and convert to since_sequence threshold. + /// + private static long? CalculateSinceSequence(string timeRange) + { + if (string.IsNullOrEmpty(timeRange)) + return null; + + // Get current timestamp + long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + // Calculate threshold based on time range + long thresholdMs = timeRange.ToLower() switch + { + "5m" => nowMs - (5 * 60 * 1000), + "15m" => nowMs - (15 * 60 * 1000), + "1h" => nowMs - (60 * 60 * 1000), + "today" => nowMs - (24 * 60 * 60 * 1000), + _ => nowMs - (60 * 60 * 1000) // Default to 1h + }; + + // Find the sequence number closest to this threshold + // Since we can't efficiently query by timestamp, we'll use a different approach: + // Query with a larger limit and filter by timestamp client-side + // For now, return null (which means "from the beginning" within the limit) + return null; + } + + #region Parameter Parsers (reuse from ActionTraceViewResource) + + private static string GetTimeRange(JObject @params) + { + var token = @params["time_range"] ?? @params["timeRange"]; + string value = token?.ToString()?.ToLower()?.Trim(); + + // Validate against allowed values + if (value == "5m" || value == "15m" || value == "1h" || value == "today") + return value; + + return "1h"; // Default + } + + private static int GetLimit(JObject @params) + { + var token = @params["limit"]; + if (token != null && int.TryParse(token.ToString(), out int limit)) + { + return Math.Clamp(limit, 1, 500); + } + return 200; // Default for summary + } + + private static string GetTaskId(JObject @params) + { + var token = @params["task_id"] ?? @params["taskId"]; + return token?.ToString(); + } + + private static string GetConversationId(JObject @params) + { + var token = @params["conversation_id"] ?? @params["conversationId"]; + return token?.ToString(); + } + + private static float GetMinImportance(JObject @params) + { + var token = @params["min_importance"] ?? @params["minImportance"]; + if (token != null) + { + string value = token?.ToString()?.ToLower()?.Trim(); + if (!string.IsNullOrEmpty(value)) + { + return value switch + { + "low" => 0.0f, + "medium" => 0.4f, + "high" => 0.7f, + "critical" => 0.9f, + _ => float.TryParse(value, out float val) ? val : 0.0f + }; + } + } + return 0.0f; // Default to low for summary (include everything) + } + + private static IReadOnlyList ApplyDisabledTypesFilter(IReadOnlyList events) + { + var settings = ActionTrace.Core.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(); + } + + private static bool IsEventTypeDisabled(string eventType, string[] disabledTypes) + { + foreach (string disabled in disabledTypes) + { + if (string.Equals(eventType, disabled, StringComparison.Ordinal)) + return true; + } + return false; + } + + private static List ApplyTaskFilters(List events, string taskId, string conversationId) + { + if (string.IsNullOrEmpty(taskId) && string.IsNullOrEmpty(conversationId)) + return events; + + return events.Where(e => + { + if (e.Type != "AINote") + return true; + + if (e.Payload == null) + return false; + + if (!string.IsNullOrEmpty(taskId)) + { + if (e.Payload.TryGetValue("task_id", out var taskVal)) + { + if (taskVal?.ToString() != taskId) + return false; + } + else + { + return false; + } + } + + if (!string.IsNullOrEmpty(conversationId)) + { + if (e.Payload.TryGetValue("conversation_id", out var convVal)) + { + if (convVal?.ToString() != conversationId) + return false; + } + } + + return true; + }).ToList(); + } + + #endregion + + /// + /// Statistics for a single target. + /// + private class TargetStats + { + public string Name { get; set; } + public int Count { get; set; } + public HashSet Types { get; set; } = new HashSet(); + } + } +} diff --git a/MCPForUnity/Editor/Tools/GetActionTraceTool.cs b/MCPForUnity/Editor/Tools/GetActionTraceTool.cs new file mode 100644 index 000000000..20199b13b --- /dev/null +++ b/MCPForUnity/Editor/Tools/GetActionTraceTool.cs @@ -0,0 +1,150 @@ +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. + /// + /// Aligned with simplified schema (Basic, WithSemantics, Aggregated). + /// Removed unsupported parameters: event_types, include_payload, include_context + /// Added summary_only for transaction aggregation mode. + /// + [McpForUnityTool("get_action_trace", Description = "Query Unity editor action trace (operation history). Returns events with optional semantic analysis or aggregated transactions. Supports query_mode preset for common AI use cases.")] + public static class GetActionTraceTool + { + /// + /// Parameters for get_action_trace tool. + /// + public class Parameters + { + /// + /// P0: Preset query mode for common AI scenarios. + /// When specified, overrides other parameters with sensible defaults: + /// - 'recent_errors': High importance only, include semantics (limit=20, min_importance=high, include_semantics=true) + /// - 'recent_changes': Substantive changes only, exclude logs (limit=30, min_importance=medium) + /// - 'summary': Minimal fields for quick overview (limit=5, min_importance=high) + /// - 'verbose': Full details with semantics (limit=100, include_semantics=true, min_importance=low) + /// + [ToolParameter("Preset mode: 'recent_errors', 'recent_changes', 'summary', 'verbose'", Required = false)] + public string QueryMode { get; set; } + + /// + /// Maximum number of events to return (1-1000, default: 50) + /// Note: Overridden by query_mode if specified + /// + [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 (for incremental queries)", Required = false)] + public long? SinceSequence { get; set; } + + /// + /// Whether to include semantic analysis results (importance, category, intent) + /// + [ToolParameter("Whether to include semantic analysis (importance, category, intent)", Required = false, DefaultValue = "false")] + public bool IncludeSemantics { get; set; } = false; + + /// + /// Minimum importance level (low/medium/high/critical) + /// Default: medium - filters out low-importance noise like HierarchyChanged + /// + [ToolParameter("Minimum importance level (low/medium/high/critical)", Required = false, DefaultValue = "medium")] + public string MinImportance { get; set; } = "medium"; + + /// + /// Return aggregated transactions instead of raw events (reduces token usage) + /// + [ToolParameter("Return aggregated transactions instead of raw events (reduces token usage)", Required = false, DefaultValue = "false")] + public bool SummaryOnly { get; set; } = false; + + /// + /// Filter by task ID (only show events associated with this task) + /// + [ToolParameter("Filter by task ID (for multi-agent scenarios)", 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. + /// Processes query_mode preset first, then delegates to ActionTraceViewResource. + /// + public static object HandleCommand(JObject @params) + { + // P0: Apply query_mode preset if specified + var processedParams = ApplyQueryMode(@params); + + // Delegate to the existing ActionTraceViewResource implementation + return ActionTraceViewResource.HandleCommand(processedParams); + } + + /// + /// P0: Apply query_mode preset to override default parameters. + /// This reduces AI's parameter construction burden for common scenarios. + /// + private static JObject ApplyQueryMode(JObject @params) + { + var queryModeToken = @params["query_mode"] ?? @params["queryMode"]; + if (queryModeToken == null) + return @params; // No query_mode, return original params + + string queryMode = queryModeToken.ToString()?.ToLower()?.Trim(); + if (string.IsNullOrEmpty(queryMode)) + return @params; + + // Create a mutable copy of params + var modifiedParams = new JObject(@params); + + switch (queryMode) + { + case "recent_errors": + // Focus on errors and critical events + modifiedParams["limit"] = 20; + modifiedParams["min_importance"] = "high"; + modifiedParams["include_semantics"] = true; + break; + + case "recent_changes": + // Substantive changes, exclude noise + modifiedParams["limit"] = 30; + modifiedParams["min_importance"] = "medium"; + // Don't include_semantics for faster query + break; + + case "summary": + // Quick overview, minimal data + modifiedParams["limit"] = 5; + modifiedParams["min_importance"] = "high"; + break; + + case "verbose": + // Full details with semantics + modifiedParams["limit"] = 100; + modifiedParams["include_semantics"] = true; + modifiedParams["min_importance"] = "low"; + break; + + default: + // Unknown query_mode, log warning but proceed + McpLog.Warn($"[GetActionTraceTool] Unknown query_mode: '{queryMode}'. Using manual parameters."); + break; + } + + return modifiedParams; + } + } +} diff --git a/MCPForUnity/Editor/Tools/ManageAsset.cs b/MCPForUnity/Editor/Tools/ManageAsset.cs index a285c9dcb..3ebe36033 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, propertiesDictionary) + /// - propertiesDictionary: property path -> new value (patch-only; no old/new diff) + /// + 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.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..07cdef59f --- /dev/null +++ b/MCPForUnity/Editor/Windows/ActionTraceEditorWindow.cs @@ -0,0 +1,1038 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.ActionTrace.Core.Store; +using MCPForUnity.Editor.ActionTrace.Analysis.Query; +using UnityEditor; +using UnityEditor.UIElements; +using UnityEngine; +using UnityEngine.UIElements; +using MCPForUnity.Editor.ActionTrace.Context; +using MCPForUnity.Editor.ActionTrace.Core.Settings; +using static MCPForUnity.Editor.ActionTrace.Analysis.Query.ActionTraceQuery; + +namespace MCPForUnity.Editor.ActionTrace.UI.Windows +{ + public enum SortMode + { + ByTimeDesc, + AIFiltered + } + + public sealed class ActionTraceEditorWindow : EditorWindow + { + #region Constants + + private const string UxmlName = "ActionTraceEditorWindow"; + private const double RefreshInterval = 1.0; + private const int DefaultQueryLimit = 200; + + private static class UINames + { + public const string EventCountBadge = "event-count-badge"; + public const string SearchField = "search-field"; + public const string FilterMenu = "filter-menu"; + public const string SortMenu = "sort-menu"; + public const string ImportanceToggle = "importance-toggle"; + public const string ContextToggle = "context-toggle"; + public const string SettingsButton = "settings-button"; + public const string RefreshButton = "refresh-button"; + public const string ClearButton = "clear-button"; + public const string FilterSummaryBar = "filter-summary-bar"; + public const string FilterSummaryText = "filter-summary-text"; + public const string ClearFiltersButton = "clear-filters-button"; + public const string EventList = "event-list"; + public const string EventListHeader = "event-list-header"; + public const string EventListCount = "event-list-count"; + public const string EmptyState = "empty-state"; + public const string NoResultsState = "no-results-state"; + public const string NoResultsFilters = "no-results-filters"; + public const string DetailScrollView = "detail-scroll-view"; + public const string DetailPlaceholder = "detail-placeholder"; + public const string DetailContent = "detail-content"; + public const string DetailActions = "detail-actions"; + public const string CopySummaryButton = "copy-summary-button"; + public const string CountLabel = "count-label"; + public const string StatusLabel = "status-label"; + public const string ModeLabel = "mode-label"; + public const string RefreshIndicator = "refresh-indicator"; + } + + private static class Classes + { + public const string EventItem = "event-item"; + public const string EventItemMainRow = "event-item-main-row"; + public const string EventItemDetailRow = "event-item-detail-row"; + public const string EventItemDetailText = "event-item-detail-text"; + public const string EventItemBadges = "event-item-badges"; + public const string EventItemBadge = "event-item-badge"; + public const string EventTime = "event-time"; + public const string EventTypeIcon = "event-type-icon"; + public const string EventType = "event-type"; + public const string EventSummary = "event-summary"; + public const string ImportanceBadge = "importance-badge"; + public const string ContextIndicator = "context-indicator"; + public const string DetailSection = "detail-section"; + public const string DetailSectionHeader = "detail-section-header"; + public const string DetailRow = "detail-row"; + public const string DetailLabel = "detail-label"; + public const string DetailValue = "detail-value"; + public const string DetailSubsection = "detail-subsection"; + public const string DetailSubsectionTitle = "detail-subsection-title"; + public const string ImportanceBarContainer = "importance-bar-container"; + public const string ImportanceBar = "importance-bar"; + public const string ImportanceBarFill = "importance-bar-fill"; + public const string ImportanceBarValue = "importance-bar-value"; + public const string ImportanceBarLabel = "importance-bar-label"; + } + + #endregion + + // UI Elements + private Label _eventCountBadge; + private ToolbarSearchField _searchField; + private ToolbarMenu _filterMenu; + private ToolbarMenu _sortMenu; + private ToolbarToggle _importanceToggle; + private ToolbarToggle _contextToggle; + private ToolbarButton _settingsButton; + private ToolbarButton _refreshButton; + private ToolbarButton _clearButton; + private VisualElement _filterSummaryBar; + private Label _filterSummaryText; + private ToolbarButton _clearFiltersButton; + private ListView _eventListView; + private Label _eventListCountLabel; + private VisualElement _emptyState; + private VisualElement _noResultsState; + private Label _noResultsFiltersLabel; + private ScrollView _detailScrollView; + private Label _detailPlaceholder; + private VisualElement _detailContent; + private VisualElement _detailActions; + private ToolbarButton _copySummaryButton; + private Label _countLabel; + private Label _statusLabel; + private Label _modeLabel; + private Label _refreshIndicator; + + // Data + private readonly List _currentEvents = new(); + private ActionTraceQuery _actionTraceQuery; + private bool? _previousBypassImportanceFilter; + + private string _searchText = string.Empty; + private float _uiMinImportance = -1f; // -1 means use Settings value, >=0 means UI override + private float _effectiveMinImportance => _uiMinImportance >= 0 ? _uiMinImportance : (ActionTraceSettings.Instance?.Filtering.MinImportanceForRecording ?? 0.4f); + private bool _showSemantics; + private bool _showContext; + private SortMode _sortMode = SortMode.ByTimeDesc; + + private double _lastRefreshTime; + private ActionTraceQuery.ActionTraceViewItem _selectedItem; + + // Performance optimization: cache + private int _lastEventStoreCount = -1; + private readonly Dictionary _iconCache = new(); + private readonly StringBuilder _stringBuilder = new(); + private bool _isScheduledRefreshActive; + private float _lastKnownSettingsImportance; + private float _lastRefreshedImportance = float.NaN; // Track last used filter value for change detection + + #region Window Management + + public static void ShowWindow() + { + var window = GetWindow("ActionTrace"); + window.minSize = new Vector2(1000, 650); + } + + #endregion + + #region UI Setup + + 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(); + SetupDetailActions(); + + _actionTraceQuery = new ActionTraceQuery(); + _uiMinImportance = -1f; // Start with "use Settings value" mode + _lastKnownSettingsImportance = ActionTraceSettings.Instance?.Filtering.MinImportanceForRecording ?? 0.4f; + + if (ActionTraceSettings.Instance != null) + { + _previousBypassImportanceFilter = ActionTraceSettings.Instance.Filtering.BypassImportanceFilter; + ActionTraceSettings.Instance.Filtering.BypassImportanceFilter = true; + } + + UpdateFilterMenuText(); + UpdateSortButtonText(); + RefreshEvents(); + UpdateStatus(); + } + + private VisualTreeAsset LoadUxmlAsset() + { + 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; + } + + 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 (_countLabel == null) + McpLog.Error($"'{UINames.CountLabel}' Label not found in UXML."); + if (_statusLabel == null) + McpLog.Error($"'{UINames.StatusLabel}' Label not found in UXML."); + } + + private void SetupReferences() + { + _eventCountBadge = rootVisualElement.Q