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