diff --git a/MCPForUnity/Editor/ActionTrace/Analysis/Context/ContextCompressor.cs b/MCPForUnity/Editor/ActionTrace/Analysis/Context/ContextCompressor.cs
new file mode 100644
index 000000000..ecf3588d1
--- /dev/null
+++ b/MCPForUnity/Editor/ActionTrace/Analysis/Context/ContextCompressor.cs
@@ -0,0 +1,450 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using MCPForUnity.Editor.ActionTrace.Core;
+using MCPForUnity.Editor.ActionTrace.Core.Models;
+using MCPForUnity.Editor.ActionTrace.Semantics;
+
+namespace MCPForUnity.Editor.ActionTrace.Analysis.Context
+{
+ ///
+ /// Configuration for context compression behavior.
+ ///
+ [Serializable]
+ public sealed class ContextCompressionConfig
+ {
+ ///
+ /// Minimum importance threshold for keeping full event payload.
+ /// Events below this will be dehydrated (payload = null).
+ ///
+ public float MinImportanceForFullPayload = 0.5f;
+
+ ///
+ /// Always keep full payload for critical events (score >= this value).
+ ///
+ public float CriticalEventThreshold = 0.9f;
+
+ ///
+ /// Always keep events with these types (regardless of importance).
+ ///
+ public string[] AlwaysKeepEventTypes = new[]
+ {
+ EventTypes.BuildFailed,
+ EventTypes.ScriptCompilationFailed,
+ EventTypes.SceneSaved,
+ "AINote"
+ };
+
+ ///
+ /// Time window for "recent events" summary (minutes).
+ /// Recent events are always kept with full payload.
+ ///
+ public int RecentEventsWindowMinutes = 10;
+
+ ///
+ /// Maximum number of events to keep in compressed context.
+ ///
+ public int MaxCompressedEvents = 200;
+
+ ///
+ /// Target compression ratio (0.0 - 1.0).
+ /// 1.0 = no compression, 0.1 = aggressive compression.
+ ///
+ public float TargetCompressionRatio = 0.3f;
+
+ ///
+ /// Enable smart preservation of asset-related events.
+ ///
+ public bool PreserveAssetEvents = true;
+
+ ///
+ /// Enable smart preservation of error/failure events.
+ ///
+ public bool PreserveErrorEvents = true;
+ }
+
+ ///
+ /// Result of context compression.
+ ///
+ public sealed class CompressedContext
+ {
+ public List PreservedEvents;
+ public List DehydratedEvents;
+ public List SummaryEvents;
+
+ // Statistics
+ public int OriginalCount;
+ public int PreservedCount;
+ public int DehydratedCount;
+ public int SummaryCount;
+ public float CompressionRatio;
+
+ public int TotalEvents => PreservedCount + DehydratedCount + SummaryCount;
+ }
+
+ ///
+ /// Compresses event context to reduce memory while preserving important information.
+ ///
+ /// Strategy:
+ /// 1. Always keep critical events (high importance, errors, builds)
+ /// 2. Keep recent events with full payload
+ /// 3. Dehydrate older events (payload = null)
+ /// 4. Generate summary for long-running operations
+ ///
+ public sealed class ContextCompressor
+ {
+ private readonly ContextCompressionConfig _config;
+ private readonly IEventScorer _scorer;
+
+ public ContextCompressor(ContextCompressionConfig config = null, IEventScorer scorer = null)
+ {
+ _config = config ?? new ContextCompressionConfig();
+ _scorer = scorer ?? new Semantics.DefaultEventScorer();
+ }
+
+ ///
+ /// Compress a list of events, preserving important information.
+ /// Returns a new list with compressed events (original list is not modified).
+ ///
+ public List Compress(IReadOnlyList events)
+ {
+ if (events == null || events.Count == 0)
+ return new List();
+
+ var result = new CompressedContext
+ {
+ OriginalCount = events.Count,
+ PreservedEvents = new List(),
+ DehydratedEvents = new List(),
+ SummaryEvents = new List()
+ };
+
+ long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+ long recentThresholdMs = nowMs - (_config.RecentEventsWindowMinutes * 60 * 1000);
+
+ // Separate events into categories
+ foreach (var evt in events)
+ {
+ if (ShouldPreserveFull(evt, nowMs, recentThresholdMs))
+ {
+ result.PreservedEvents.Add(evt);
+ }
+ else
+ {
+ // Create dehydrated copy
+ var dehydrated = evt.Dehydrate();
+ result.DehydratedEvents.Add(dehydrated);
+ }
+ }
+
+ // Sort preserved events by timestamp
+ result.PreservedEvents.Sort((a, b) => a.TimestampUnixMs.CompareTo(b.TimestampUnixMs));
+
+ // Limit total count if needed
+ int totalAfterPreserve = result.PreservedEvents.Count + result.DehydratedEvents.Count;
+ if (totalAfterPreserve > _config.MaxCompressedEvents)
+ {
+ // Keep all preserved, trim dehydrated
+ int maxDehydrated = _config.MaxCompressedEvents - result.PreservedEvents.Count;
+ if (maxDehydrated < 0) maxDehydrated = 0;
+
+ // Keep most recent dehydrated events
+ result.DehydratedEvents = result.DehydratedEvents
+ .OrderByDescending(e => e.TimestampUnixMs)
+ .Take(maxDehydrated)
+ .ToList();
+ }
+
+ // Update statistics
+ result.PreservedCount = result.PreservedEvents.Count;
+ result.DehydratedCount = result.DehydratedEvents.Count;
+ result.CompressionRatio = result.OriginalCount > 0
+ ? (float)result.TotalEvents / result.OriginalCount
+ : 1f;
+
+ // Combine results
+ var compressed = new List(result.TotalEvents);
+ compressed.AddRange(result.PreservedEvents);
+ compressed.AddRange(result.DehydratedEvents);
+
+ // Sort by timestamp
+ compressed.Sort((a, b) => a.TimestampUnixMs.CompareTo(b.TimestampUnixMs));
+
+ return compressed;
+ }
+
+ ///
+ /// Compress with detailed result information.
+ ///
+ public CompressedContext CompressWithDetails(IReadOnlyList events)
+ {
+ if (events == null || events.Count == 0)
+ return new CompressedContext
+ {
+ OriginalCount = 0,
+ PreservedEvents = new List(),
+ DehydratedEvents = new List(),
+ SummaryEvents = new List()
+ };
+
+ var result = new CompressedContext
+ {
+ OriginalCount = events.Count,
+ PreservedEvents = new List(),
+ DehydratedEvents = new List(),
+ SummaryEvents = new List()
+ };
+
+ long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+ long recentThresholdMs = nowMs - (_config.RecentEventsWindowMinutes * 60 * 1000);
+
+ foreach (var evt in events)
+ {
+ if (ShouldPreserveFull(evt, nowMs, recentThresholdMs))
+ {
+ result.PreservedEvents.Add(evt);
+ }
+ else
+ {
+ var dehydrated = evt.Dehydrate();
+ result.DehydratedEvents.Add(dehydrated);
+ }
+ }
+
+ result.PreservedEvents.Sort((a, b) => a.TimestampUnixMs.CompareTo(b.TimestampUnixMs));
+
+ // Apply size limits
+ ApplySizeLimits(result);
+
+ result.PreservedCount = result.PreservedEvents.Count;
+ result.DehydratedCount = result.DehydratedEvents.Count;
+ result.CompressionRatio = result.OriginalCount > 0
+ ? (float)result.TotalEvents / result.OriginalCount
+ : 1f;
+
+ return result;
+ }
+
+ ///
+ /// Generate a summary of events for a time range.
+ /// Useful for compressing long event sequences.
+ ///
+ public string SummarizeEvents(IReadOnlyList events, int startIdx, int count)
+ {
+ if (events == null || startIdx < 0 || startIdx >= events.Count || count <= 0)
+ return string.Empty;
+
+ int endIdx = Math.Min(startIdx + count, events.Count);
+ int actualCount = endIdx - startIdx;
+
+ if (actualCount == 0)
+ return string.Empty;
+
+ // Count by type
+ var typeCounts = new Dictionary();
+ for (int i = startIdx; i < endIdx; i++)
+ {
+ string type = events[i].Type ?? "Unknown";
+ typeCounts.TryGetValue(type, out int existingCount);
+ typeCounts[type] = existingCount + 1;
+ }
+
+ // Build summary
+ var summary = new System.Text.StringBuilder();
+ summary.Append(actualCount).Append(" events: ");
+
+ int shown = 0;
+ foreach (var kvp in typeCounts.OrderByDescending(x => x.Value))
+ {
+ if (shown > 0) summary.Append(", ");
+ summary.Append(kvp.Key).Append(" (").Append(kvp.Value).Append(")");
+ shown++;
+ if (shown >= 5) break;
+ }
+
+ if (typeCounts.Count > 5)
+ {
+ summary.Append(", ...");
+ }
+
+ return summary.ToString();
+ }
+
+ ///
+ /// Check if an event should be preserved with full payload.
+ ///
+ private bool ShouldPreserveFull(EditorEvent evt, long nowMs, long recentThresholdMs)
+ {
+ // Always preserve if in "always keep" list
+ if (_config.AlwaysKeepEventTypes != null)
+ {
+ foreach (string type in _config.AlwaysKeepEventTypes)
+ {
+ if (evt.Type == type)
+ return true;
+ }
+ }
+
+ // Always preserve critical events
+ float importance = _scorer.Score(evt);
+ if (importance >= _config.CriticalEventThreshold)
+ return true;
+
+ // Preserve recent events
+ if (evt.TimestampUnixMs >= recentThresholdMs)
+ return true;
+
+ // Preserve high importance events
+ if (importance >= _config.MinImportanceForFullPayload)
+ return true;
+
+ // Preserve asset events if configured
+ if (_config.PreserveAssetEvents && IsAssetEvent(evt))
+ return true;
+
+ // Preserve error events if configured
+ if (_config.PreserveErrorEvents && IsErrorEvent(evt))
+ return true;
+
+ return false;
+ }
+
+ private bool IsAssetEvent(EditorEvent evt)
+ {
+ return evt.Type == EventTypes.AssetImported ||
+ evt.Type == EventTypes.AssetCreated ||
+ evt.Type == EventTypes.AssetDeleted ||
+ evt.Type == EventTypes.AssetMoved ||
+ evt.Type == EventTypes.AssetModified;
+ }
+
+ private bool IsErrorEvent(EditorEvent evt)
+ {
+ return evt.Type == EventTypes.BuildFailed ||
+ evt.Type == EventTypes.ScriptCompilationFailed ||
+ (evt.Payload != null && evt.Payload.ContainsKey("error"));
+ }
+
+ private void ApplySizeLimits(CompressedContext result)
+ {
+ int totalAfterPreserve = result.PreservedEvents.Count + result.DehydratedEvents.Count;
+
+ if (totalAfterPreserve <= _config.MaxCompressedEvents)
+ return;
+
+ int maxDehydrated = _config.MaxCompressedEvents - result.PreservedEvents.Count;
+ if (maxDehydrated < 0) maxDehydrated = 0;
+
+ // Keep most recent dehydrated events
+ result.DehydratedEvents = result.DehydratedEvents
+ .OrderByDescending(e => e.TimestampUnixMs)
+ .Take(maxDehydrated)
+ .ToList();
+ }
+ }
+
+ ///
+ /// Extension methods for context compression.
+ ///
+ public static class ContextCompressionExtensions
+ {
+ ///
+ /// Compress events with default configuration.
+ ///
+ public static List Compress(this IReadOnlyList events)
+ {
+ var compressor = new ContextCompressor();
+ return compressor.Compress(events);
+ }
+
+ ///
+ /// Compress events with custom configuration.
+ ///
+ public static List Compress(this IReadOnlyList events, ContextCompressionConfig config)
+ {
+ var compressor = new ContextCompressor(config);
+ return compressor.Compress(events);
+ }
+
+ ///
+ /// Compress events targeting a specific count.
+ ///
+ public static List CompressTo(this IReadOnlyList events, int targetCount)
+ {
+ var config = new ContextCompressionConfig { MaxCompressedEvents = targetCount };
+ var compressor = new ContextCompressor(config);
+ return compressor.Compress(events);
+ }
+
+ ///
+ /// Get recent events within a time window.
+ ///
+ public static List GetRecent(this IReadOnlyList events, int minutes)
+ {
+ if (events == null || events.Count == 0)
+ return new List();
+
+ long thresholdMs = DateTimeOffset.UtcNow.AddMinutes(-minutes).ToUnixTimeMilliseconds();
+
+ return events
+ .Where(e => e.TimestampUnixMs >= thresholdMs)
+ .OrderBy(e => e.TimestampUnixMs)
+ .ToList();
+ }
+
+ ///
+ /// Get high importance events.
+ ///
+ public static List GetHighImportance(this IReadOnlyList events, float threshold = 0.7f)
+ {
+ if (events == null || events.Count == 0)
+ return new List();
+
+ var scorer = new Semantics.DefaultEventScorer();
+
+ return events
+ .Where(e => scorer.Score(e) >= threshold)
+ .OrderByDescending(e => e.TimestampUnixMs)
+ .ToList();
+ }
+
+ ///
+ /// Get events of specific types.
+ ///
+ public static List GetByTypes(this IReadOnlyList events, params string[] types)
+ {
+ if (events == null || events.Count == 0 || types == null || types.Length == 0)
+ return new List();
+
+ var typeSet = new HashSet(types);
+ return events.Where(e => typeSet.Contains(e.Type)).ToList();
+ }
+
+ ///
+ /// Deduplicate events by target and type within a time window.
+ ///
+ public static List Deduplicate(this IReadOnlyList events, int windowMs = 100)
+ {
+ if (events == null || events.Count == 0)
+ return new List();
+ if (windowMs <= 0)
+ throw new ArgumentOutOfRangeException(nameof(windowMs), "windowMs must be > 0.");
+
+ var seen = new HashSet();
+ var result = new List();
+
+ // Process in chronological order
+ foreach (var evt in events.OrderBy(e => e.TimestampUnixMs))
+ {
+ string key = $"{evt.Type}|{evt.TargetId}|{evt.TimestampUnixMs / windowMs}";
+
+ if (seen.Add(key))
+ {
+ result.Add(evt);
+ }
+ }
+
+ return result;
+ }
+ }
+}
diff --git a/MCPForUnity/Editor/ActionTrace/Analysis/Query/ActionTraceQuery.cs b/MCPForUnity/Editor/ActionTrace/Analysis/Query/ActionTraceQuery.cs
new file mode 100644
index 000000000..ad0377ae5
--- /dev/null
+++ b/MCPForUnity/Editor/ActionTrace/Analysis/Query/ActionTraceQuery.cs
@@ -0,0 +1,324 @@
+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 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.
+ ///
+ public IReadOnlyList Project(IReadOnlyList events)
+ {
+ if (events == null || events.Count == 0)
+ return Array.Empty();
+
+ var result = new ActionTraceViewItem[events.Count];
+
+ for (int i = 0; i < events.Count; i++)
+ {
+ var evt = events[i];
+
+ // Compute importance score
+ var score = _scorer.Score(evt);
+
+ // Categorize the score
+ var category = _categorizer.Categorize(score);
+
+ // Compute context window (5 events before and after current event) for intent inference
+ int contextWindow = 5;
+ int contextStart = Math.Max(0, i - contextWindow);
+ int contextEnd = Math.Min(events.Count, i + contextWindow + 1);
+ int contextLength = contextEnd - contextStart;
+
+ EditorEvent[] surrounding = null;
+ if (contextLength > 0)
+ {
+ surrounding = new EditorEvent[contextLength];
+
+ // Performance: EventStore queries are usually in chronological order (but Query returns may be descending).
+ // Detect order in O(1) (compare first/last sequence) and fill surrounding in chronological order if needed
+ bool isDescending = events.Count > 1 && events[0].Sequence > events[events.Count - 1].Sequence;
+
+ if (!isDescending)
+ {
+ for (int j = 0; j < contextLength; j++)
+ {
+ surrounding[j] = events[contextStart + j];
+ }
+ }
+ else
+ {
+ // events are descending (newest first), need to build surrounding in ascending order (oldest->newest)
+ // Fill from contextEnd-1 down to contextStart to produce ascending window
+ for (int j = 0; j < contextLength; j++)
+ {
+ surrounding[j] = events[contextEnd - 1 - j];
+ }
+ }
+ }
+
+ // Use surrounding parameter for intent inference (in chronological order)
+ var intent = _inferrer.Infer(evt, surrounding);
+
+ // Use EditorEvent's GetSummary() method, which automatically handles dehydrated events
+ var displaySummary = evt.GetSummary();
+ var displaySummaryLower = (displaySummary ?? string.Empty).ToLowerInvariant();
+ var displayTargetIdLower = (evt.TargetId ?? string.Empty).ToLowerInvariant();
+
+ // Format as local time including date: MM-dd HH:mm
+ var localTime = DateTimeOffset.FromUnixTimeMilliseconds(evt.TimestampUnixMs).ToLocalTime();
+ var displayTime = localTime.ToString("MM-dd HH:mm");
+ var displaySequence = evt.Sequence.ToString();
+
+ // Precompute colors
+ var typeColor = GetEventTypeColor(evt.Type);
+ var importanceColor = GetImportanceColor(category);
+ var importanceBadgeColor = GetImportanceBadgeColor(category);
+
+ result[i] = new ActionTraceViewItem
+ {
+ Event = evt,
+ ImportanceScore = score,
+ ImportanceCategory = category,
+ InferredIntent = intent,
+ // Set display cache
+ DisplaySummary = displaySummary,
+ DisplaySummaryLower = displaySummaryLower,
+ DisplayTargetIdLower = displayTargetIdLower,
+ DisplayTime = displayTime,
+ DisplaySequence = displaySequence,
+ TypeColor = typeColor,
+ ImportanceColor = importanceColor,
+ ImportanceBadgeColor = importanceBadgeColor
+ };
+ }
+
+ return result;
+ }
+
+ ///
+ /// Project events with context associations.
+ /// Overload for QueryWithContext results.
+ ///
+ public IReadOnlyList ProjectWithContext(
+ IReadOnlyList<(EditorEvent Event, ContextMapping Context)> eventsWithContext)
+ {
+ if (eventsWithContext == null || eventsWithContext.Count == 0)
+ return Array.Empty();
+
+ var result = new ActionTraceViewItem[eventsWithContext.Count];
+
+ for (int i = 0; i < eventsWithContext.Count; i++)
+ {
+ var (evt, ctx) = eventsWithContext[i];
+
+ var score = _scorer.Score(evt);
+ var category = _categorizer.Categorize(score);
+
+ // Use simple inference to avoid List allocation
+ var intent = _inferrer.Infer(evt, surrounding: null);
+
+ // Use EditorEvent's GetSummary() method, which automatically handles dehydrated events
+ var displaySummary = evt.GetSummary();
+ var displaySummaryLower = (displaySummary ?? string.Empty).ToLowerInvariant();
+ var displayTargetIdLower = (evt.TargetId ?? string.Empty).ToLowerInvariant();
+
+ // Format as local time including date: MM-dd HH:mm
+ var localTime = DateTimeOffset.FromUnixTimeMilliseconds(evt.TimestampUnixMs).ToLocalTime();
+ var displayTime = localTime.ToString("MM-dd HH:mm");
+ var displaySequence = evt.Sequence.ToString();
+
+ // Precompute colors
+ var typeColor = GetEventTypeColor(evt.Type);
+ var importanceColor = GetImportanceColor(category);
+ var importanceBadgeColor = GetImportanceBadgeColor(category);
+
+ result[i] = new ActionTraceViewItem
+ {
+ Event = evt,
+ Context = ctx,
+ ImportanceScore = score,
+ ImportanceCategory = category,
+ InferredIntent = intent,
+ // Set display cache
+ DisplaySummary = displaySummary,
+ DisplaySummaryLower = displaySummaryLower,
+ DisplayTargetIdLower = displayTargetIdLower,
+ DisplayTime = displayTime,
+ DisplaySequence = displaySequence,
+ TypeColor = typeColor,
+ ImportanceColor = importanceColor,
+ ImportanceBadgeColor = importanceBadgeColor
+ };
+ }
+
+ return result;
+ }
+
+ ///
+ /// Get event type color for display.
+ /// Uses cached values to avoid repeated allocations.
+ ///
+ private static Color GetEventTypeColor(string eventType)
+ {
+ return EventTypeColors.TryGetValue(eventType, out var color) ? color : Color.gray;
+ }
+
+ ///
+ /// Get importance background color (nullable).
+ /// Uses cached values to avoid repeated allocations.
+ ///
+ private static Color? GetImportanceColor(string category)
+ {
+ return ImportanceColors.TryGetValue(category, out var color) ? color : null;
+ }
+
+ ///
+ /// Get importance badge color.
+ /// Uses cached values to avoid repeated allocations.
+ ///
+ private static Color GetImportanceBadgeColor(string category)
+ {
+ return ImportanceBadgeColors.TryGetValue(category, out var color) ? color : Color.gray;
+ }
+
+ ///
+ /// A view of an event with projected semantic information.
+ /// This is a computed projection, not stored data.
+ ///
+ /// Performance optimization: All display strings are precomputed at projection time
+ /// to avoid repeated allocations in OnGUI.
+ ///
+ public sealed class ActionTraceViewItem
+ {
+ ///
+ /// The original immutable event.
+ ///
+ public EditorEvent Event { get; set; }
+
+ ///
+ /// Optional context association (may be null).
+ ///
+ public ContextMapping Context { get; set; }
+
+ ///
+ /// Computed importance score (0.0 to 1.0).
+ /// Higher values indicate more important events.
+ ///
+ public float ImportanceScore { get; set; }
+
+ ///
+ /// Category label derived from importance score.
+ /// Values: "critical", "high", "medium", "low"
+ ///
+ public string ImportanceCategory { get; set; }
+
+ ///
+ /// Inferred user intent or purpose.
+ /// May be null if intent cannot be determined.
+ ///
+ public string InferredIntent { get; set; }
+
+ // ========== Display cache (avoid repeated allocations in OnGUI) ==========
+
+ ///
+ /// Precomputed event summary for display.
+ ///
+ public string DisplaySummary { get; set; }
+
+ ///
+ /// Precomputed summary in lowercase for search filtering.
+ ///
+ public string DisplaySummaryLower { get; set; }
+
+ ///
+ /// Precomputed target ID in lowercase for search filtering.
+ ///
+ public string DisplayTargetIdLower { get; set; }
+
+ ///
+ /// Precomputed formatted time (HH:mm:ss).
+ ///
+ public string DisplayTime { get; set; }
+
+ ///
+ /// Precomputed sequence number as string.
+ ///
+ public string DisplaySequence { get; set; }
+
+ ///
+ /// Precomputed event type color (avoid switch during rendering).
+ ///
+ public Color TypeColor { get; set; }
+
+ ///
+ /// Precomputed importance background color.
+ ///
+ public Color? ImportanceColor { get; set; }
+
+ ///
+ /// Precomputed importance badge color.
+ ///
+ public Color ImportanceBadgeColor { get; set; }
+ }
+ }
+}
diff --git a/MCPForUnity/Editor/ActionTrace/Analysis/Query/EventQueryBuilder.cs b/MCPForUnity/Editor/ActionTrace/Analysis/Query/EventQueryBuilder.cs
new file mode 100644
index 000000000..0522bf15a
--- /dev/null
+++ b/MCPForUnity/Editor/ActionTrace/Analysis/Query/EventQueryBuilder.cs
@@ -0,0 +1,799 @@
+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.Semantics;
+
+namespace MCPForUnity.Editor.ActionTrace.Analysis.Query
+{
+ ///
+ /// Sort order for query results.
+ ///
+ public enum QuerySortOrder
+ {
+ NewestFirst, // Descending by timestamp
+ OldestFirst, // Ascending by timestamp
+ HighestImportance,
+ LowestImportance,
+ MostRecentTarget
+ }
+
+ ///
+ /// Time range filter for queries.
+ ///
+ public readonly struct QueryTimeRange
+ {
+ public readonly long StartMs;
+ public readonly long EndMs;
+
+ public QueryTimeRange(long startMs, long endMs)
+ {
+ StartMs = startMs;
+ EndMs = endMs;
+ }
+
+ ///
+ /// Last N minutes from now.
+ ///
+ public static QueryTimeRange LastMinutes(int minutes)
+ {
+ long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+ return new QueryTimeRange(now - minutes * 60000, now);
+ }
+
+ ///
+ /// Last N hours from now.
+ ///
+ public static QueryTimeRange LastHours(int hours)
+ {
+ long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+ return new QueryTimeRange(now - hours * 3600000, now);
+ }
+
+ ///
+ /// Between two Unix timestamps.
+ ///
+ public static QueryTimeRange Between(long startMs, long endMs)
+ {
+ return new QueryTimeRange(startMs, endMs);
+ }
+
+ ///
+ /// Since a specific Unix timestamp.
+ ///
+ public static QueryTimeRange Since(long timestampMs)
+ {
+ return new QueryTimeRange(timestampMs, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
+ }
+ }
+
+ ///
+ /// Fluent builder for querying ActionTrace events.
+ ///
+ /// Provides a chainable API for common query patterns:
+ ///
+ /// var results = EventQuery.Query()
+ /// .OfType(EventTypes.PropertyModified)
+ /// .WithImportance(ImportanceLevel.High)
+ /// .InLastMinutes(10)
+ /// .OrderBy(QuerySortOrder.NewestFirst)
+ /// .Limit(50)
+ /// .Execute();
+ ///
+ public sealed class EventQueryBuilder
+ {
+ private readonly IEventScorer _scorer;
+ private readonly IEventCategorizer _categorizer;
+
+ // P1 Fix: Score cache to avoid repeated scoring of the same events
+ private readonly Dictionary _scoreCache = new();
+
+ // Filter state
+ private HashSet _includedTypes;
+ private HashSet _excludedTypes;
+ private HashSet _includedCategories;
+ private HashSet _excludedCategories;
+ private HashSet _includedTargets;
+ private HashSet _searchTerms;
+ private float? _minImportance;
+ private float? _maxImportance;
+ private QueryTimeRange? _timeRange;
+ private QuerySortOrder _sortOrder = QuerySortOrder.NewestFirst;
+ private int? _limit;
+ private int? _offset;
+
+ public EventQueryBuilder(IEventScorer scorer = null, IEventCategorizer categorizer = null)
+ {
+ _scorer = scorer ?? new Semantics.DefaultEventScorer();
+ _categorizer = categorizer ?? new Semantics.DefaultCategorizer();
+ }
+
+ // ========== Type Filters ==========
+
+ ///
+ /// Filter to events of the specified type.
+ ///
+ public EventQueryBuilder OfType(string eventType)
+ {
+ _includedTypes ??= new HashSet();
+ _includedTypes.Add(eventType);
+ return this;
+ }
+
+ ///
+ /// Filter to events of any of the specified types.
+ ///
+ public EventQueryBuilder OfTypes(params string[] eventTypes)
+ {
+ _includedTypes ??= new HashSet();
+ foreach (string type in eventTypes)
+ _includedTypes.Add(type);
+ return this;
+ }
+
+ ///
+ /// Exclude events of the specified type.
+ ///
+ public EventQueryBuilder NotOfType(string eventType)
+ {
+ _excludedTypes ??= new HashSet();
+ _excludedTypes.Add(eventType);
+ return this;
+ }
+
+ ///
+ /// Exclude events of any of the specified types.
+ ///
+ public EventQueryBuilder NotOfTypes(params string[] eventTypes)
+ {
+ _excludedTypes ??= new HashSet();
+ foreach (string type in eventTypes)
+ _excludedTypes.Add(type);
+ return this;
+ }
+
+ // ========== Category Filters ==========
+
+ ///
+ /// Filter to events in the specified category.
+ ///
+ public EventQueryBuilder InCategory(EventCategory category)
+ {
+ _includedCategories ??= new HashSet();
+ _includedCategories.Add(category);
+ return this;
+ }
+
+ ///
+ /// Filter to events in any of the specified categories.
+ ///
+ public EventQueryBuilder InCategories(params EventCategory[] categories)
+ {
+ _includedCategories ??= new HashSet();
+ foreach (var cat in categories)
+ _includedCategories.Add(cat);
+ return this;
+ }
+
+ ///
+ /// Exclude events in the specified category.
+ ///
+ public EventQueryBuilder NotInCategory(EventCategory category)
+ {
+ _excludedCategories ??= new HashSet();
+ _excludedCategories.Add(category);
+ return this;
+ }
+
+ // ========== Target Filters ==========
+
+ ///
+ /// Filter to events for the specified target ID.
+ ///
+ public EventQueryBuilder ForTarget(string targetId)
+ {
+ _includedTargets ??= new HashSet();
+ _includedTargets.Add(targetId);
+ return this;
+ }
+
+ ///
+ /// Filter to events for any of the specified targets.
+ ///
+ public EventQueryBuilder ForTargets(params string[] targetIds)
+ {
+ _includedTargets ??= new HashSet();
+ foreach (string id in targetIds)
+ _includedTargets.Add(id);
+ return this;
+ }
+
+ // ========== Importance Filters ==========
+
+ ///
+ /// Filter to events with minimum importance score.
+ ///
+ public EventQueryBuilder WithMinImportance(float minScore)
+ {
+ _minImportance = minScore;
+ return this;
+ }
+
+ ///
+ /// Filter to events with maximum importance score.
+ ///
+ public EventQueryBuilder WithMaxImportance(float maxScore)
+ {
+ _maxImportance = maxScore;
+ return this;
+ }
+
+ ///
+ /// Filter to events within an importance range.
+ ///
+ public EventQueryBuilder WithImportanceBetween(float minScore, float maxScore)
+ {
+ _minImportance = minScore;
+ _maxImportance = maxScore;
+ return this;
+ }
+
+ ///
+ /// Filter to critical events only.
+ ///
+ public EventQueryBuilder CriticalOnly()
+ {
+ return WithMinImportance(0.9f);
+ }
+
+ ///
+ /// Filter to important events (high and critical).
+ ///
+ public EventQueryBuilder ImportantOnly()
+ {
+ return WithMinImportance(0.7f);
+ }
+
+ // ========== Time Filters ==========
+
+ ///
+ /// Filter to events within the specified time range.
+ ///
+ public EventQueryBuilder InTimeRange(QueryTimeRange range)
+ {
+ _timeRange = range;
+ return this;
+ }
+
+ ///
+ /// Filter to events in the last N minutes.
+ ///
+ public EventQueryBuilder InLastMinutes(int minutes)
+ {
+ _timeRange = QueryTimeRange.LastMinutes(minutes);
+ return this;
+ }
+
+ ///
+ /// Filter to events in the last N hours.
+ ///
+ public EventQueryBuilder InLastHours(int hours)
+ {
+ _timeRange = QueryTimeRange.LastHours(hours);
+ return this;
+ }
+
+ ///
+ /// Filter to events since a specific timestamp.
+ ///
+ public EventQueryBuilder Since(long timestampMs)
+ {
+ _timeRange = QueryTimeRange.Since(timestampMs);
+ return this;
+ }
+
+ ///
+ /// Filter to events between two timestamps.
+ ///
+ public EventQueryBuilder Between(long startMs, long endMs)
+ {
+ _timeRange = QueryTimeRange.Between(startMs, endMs);
+ return this;
+ }
+
+ // ========== Search Filters ==========
+
+ ///
+ /// Filter to events containing any of the search terms (case-insensitive).
+ /// Searches in summary text and target ID.
+ ///
+ public EventQueryBuilder WithSearchTerm(string term)
+ {
+ _searchTerms ??= new HashSet(StringComparer.OrdinalIgnoreCase);
+ _searchTerms.Add(term);
+ return this;
+ }
+
+ ///
+ /// Filter to events containing all of the search terms.
+ ///
+ public EventQueryBuilder WithAllSearchTerms(params string[] terms)
+ {
+ _searchTerms ??= new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (string term in terms)
+ _searchTerms.Add(term);
+ return this;
+ }
+
+ // ========== Sort & Pagination ==========
+
+ ///
+ /// Set the sort order for results.
+ ///
+ public EventQueryBuilder OrderBy(QuerySortOrder order)
+ {
+ _sortOrder = order;
+ return this;
+ }
+
+ ///
+ /// Limit the number of results.
+ ///
+ public EventQueryBuilder Limit(int count)
+ {
+ _limit = count;
+ return this;
+ }
+
+ ///
+ /// Skip the first N results (for pagination).
+ ///
+ public EventQueryBuilder Skip(int count)
+ {
+ _offset = count;
+ return this;
+ }
+
+ ///
+ /// Set pagination with page number and page size.
+ ///
+ public EventQueryBuilder Page(int pageNumber, int pageSize)
+ {
+ _offset = pageNumber * pageSize;
+ _limit = pageSize;
+ return this;
+ }
+
+ // ========== Execution ==========
+
+ ///
+ /// Execute the query and return matching events.
+ ///
+ /// Performance optimization:
+ /// - When a limit is specified, uses Query(limit) to avoid loading all events
+ /// - When a recent time range is specified, estimates a reasonable limit
+ /// - Falls back to QueryAll() only when necessary
+ /// - P1 Fix: Clears score cache before execution to prevent memory growth
+ ///
+ public List Execute()
+ {
+ // P1 Fix: Clear score cache before execution
+ _scoreCache.Clear();
+
+ // Calculate effective limit to avoid full table scan
+ int effectiveLimit = CalculateEffectiveLimit();
+ IReadOnlyList allEvents;
+
+ if (effectiveLimit > 0 && effectiveLimit < int.MaxValue)
+ {
+ // Use Query(limit) to get only the most recent events
+ allEvents = EventStore.Query(effectiveLimit);
+ }
+ else
+ {
+ // Fall back to full query (uncommon case)
+ allEvents = EventStore.QueryAll();
+ }
+
+ // Apply filters
+ var filtered = allEvents.Where(MatchesFilters);
+
+ // Sort
+ filtered = ApplySorting(filtered);
+
+ // Pagination
+ if (_offset.HasValue)
+ filtered = filtered.Skip(_offset.Value);
+ if (_limit.HasValue)
+ filtered = filtered.Take(_limit.Value);
+
+ return filtered.ToList();
+ }
+
+ ///
+ /// Calculate an effective limit for EventStore.Query() to avoid loading all events.
+ /// Returns a reasonable limit based on the query constraints.
+ ///
+ private int CalculateEffectiveLimit()
+ {
+ // If user specified a limit, use it (plus offset for safety)
+ if (_limit.HasValue)
+ {
+ int result = _limit.Value;
+ if (_offset.HasValue)
+ result += _offset.Value;
+ // Add buffer for filtering (some events may be filtered out)
+ return result * 2 + 50;
+ }
+
+ // If recent time range is specified, estimate limit
+ if (_timeRange.HasValue)
+ {
+ var range = _timeRange.Value;
+ long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+ long rangeMs = now - range.StartMs;
+
+ // Estimate: assume ~5 events per second in active editing
+ // This is a rough heuristic to limit the initial load
+ if (rangeMs < 300000) // Less than 5 minutes
+ return 500;
+ if (rangeMs < 3600000) // Less than 1 hour
+ return 2000;
+ if (rangeMs < 86400000) // Less than 1 day
+ return 5000;
+ }
+
+ // No limit specified and no recent time range - need full scan
+ return int.MaxValue;
+ }
+
+ ///
+ /// Execute the query and return projected view items.
+ ///
+ public List ExecuteProjected()
+ {
+ var events = Execute();
+ var query = new Analysis.Query.ActionTraceQuery(_scorer, _categorizer, null);
+ return query.Project(events).ToList();
+ }
+
+ ///
+ /// Execute the query and return the first matching event, or null.
+ ///
+ public EditorEvent FirstOrDefault()
+ {
+ return Execute().FirstOrDefault();
+ }
+
+ ///
+ /// Execute the query and return the last matching event, or null.
+ ///
+ public EditorEvent LastOrDefault()
+ {
+ return Execute().LastOrDefault();
+ }
+
+ ///
+ /// Count events matching the query (without fetching full results).
+ /// Uses optimization to avoid loading all events when possible.
+ ///
+ public int Count()
+ {
+ int effectiveLimit = CalculateEffectiveLimit();
+ IReadOnlyList events;
+
+ if (effectiveLimit > 0 && effectiveLimit < int.MaxValue)
+ {
+ events = EventStore.Query(effectiveLimit);
+ }
+ else
+ {
+ events = EventStore.QueryAll();
+ }
+
+ return events.Count(MatchesFilters);
+ }
+
+ ///
+ /// Check if any events match the query.
+ /// Uses optimization to avoid loading all events when possible.
+ ///
+ public bool Any()
+ {
+ int effectiveLimit = CalculateEffectiveLimit();
+ IReadOnlyList events;
+
+ if (effectiveLimit > 0 && effectiveLimit < int.MaxValue)
+ {
+ events = EventStore.Query(effectiveLimit);
+ }
+ else
+ {
+ events = EventStore.QueryAll();
+ }
+
+ return events.Any(MatchesFilters);
+ }
+
+ // ========== Internal ==========
+
+ private bool MatchesFilters(EditorEvent evt)
+ {
+ // Type filters
+ if (_includedTypes != null && !_includedTypes.Contains(evt.Type))
+ return false;
+
+ if (_excludedTypes != null && _excludedTypes.Contains(evt.Type))
+ return false;
+
+ // Category filters
+ if (_includedCategories != null || _excludedCategories != null)
+ {
+ var meta = EventTypes.Metadata.Get(evt.Type);
+ EventCategory category = meta.Category;
+
+ if (_includedCategories != null && !_includedCategories.Contains(category))
+ return false;
+
+ if (_excludedCategories != null && _excludedCategories.Contains(category))
+ return false;
+ }
+
+ // Target filters
+ if (_includedTargets != null && !_includedTargets.Contains(evt.TargetId))
+ return false;
+
+ // Importance filters (P1 Fix: Use cached score)
+ float score = GetCachedScore(evt);
+ if (_minImportance.HasValue && score < _minImportance.Value)
+ return false;
+
+ if (_maxImportance.HasValue && score > _maxImportance.Value)
+ return false;
+
+ // Time range filters
+ if (_timeRange.HasValue)
+ {
+ var range = _timeRange.Value;
+ if (evt.TimestampUnixMs < range.StartMs || evt.TimestampUnixMs > range.EndMs)
+ return false;
+ }
+
+ // Search filters
+ if (_searchTerms != null && _searchTerms.Count > 0)
+ {
+ string summary = (evt.GetSummary() ?? "").ToLowerInvariant();
+ string target = (evt.TargetId ?? "").ToLowerInvariant();
+
+ bool matchesAny = false;
+ foreach (string term in _searchTerms)
+ {
+ string lowerTerm = term.ToLowerInvariant();
+ if (summary.Contains(lowerTerm) || target.Contains(lowerTerm))
+ {
+ matchesAny = true;
+ break;
+ }
+ }
+
+ if (!matchesAny)
+ return false;
+ }
+
+ return true;
+ }
+
+ ///
+ /// Get the importance score for an event, using cache to avoid repeated computation.
+ /// P1 Fix: Added to optimize repeated scoring in queries.
+ ///
+ private float GetCachedScore(EditorEvent evt)
+ {
+ if (_scoreCache.TryGetValue(evt.Sequence, out float cachedScore))
+ return cachedScore;
+
+ float score = _scorer.Score(evt);
+ _scoreCache[evt.Sequence] = score;
+ return score;
+ }
+
+ private IEnumerable ApplySorting(IEnumerable source)
+ {
+ return _sortOrder switch
+ {
+ QuerySortOrder.NewestFirst => source.OrderByDescending(e => e.TimestampUnixMs),
+ QuerySortOrder.OldestFirst => source.OrderBy(e => e.TimestampUnixMs),
+ QuerySortOrder.HighestImportance => source.OrderByDescending(e => GetCachedScore(e)),
+ QuerySortOrder.LowestImportance => source.OrderBy(e => GetCachedScore(e)),
+ QuerySortOrder.MostRecentTarget => source.GroupBy(e => e.TargetId)
+ .Select(g => g.OrderByDescending(e => e.TimestampUnixMs).First())
+ .OrderByDescending(e => e.TimestampUnixMs),
+ _ => source.OrderByDescending(e => e.TimestampUnixMs)
+ };
+ }
+ }
+
+ ///
+ /// Static entry point for creating queries.
+ ///
+ public static class EventQuery
+ {
+ ///
+ /// Create a new query builder.
+ ///
+ public static EventQueryBuilder Query()
+ {
+ return new EventQueryBuilder();
+ }
+
+ ///
+ /// Create a query with custom semantic components.
+ ///
+ public static EventQueryBuilder Query(IEventScorer scorer, IEventCategorizer categorizer = null)
+ {
+ return new EventQueryBuilder(scorer, categorizer);
+ }
+
+ ///
+ /// Get all events (unfiltered).
+ ///
+ public static List All()
+ {
+ return EventStore.QueryAll().ToList();
+ }
+
+ ///
+ /// Get recent events from the last N minutes.
+ ///
+ public static List Recent(int minutes = 10)
+ {
+ return Query()
+ .InLastMinutes(minutes)
+ .Execute();
+ }
+
+ ///
+ /// Get events for a specific target.
+ ///
+ public static List ForTarget(string targetId)
+ {
+ return Query()
+ .ForTarget(targetId)
+ .Execute();
+ }
+
+ ///
+ /// Get critical events.
+ ///
+ public static List Critical()
+ {
+ return Query()
+ .CriticalOnly()
+ .Execute();
+ }
+
+ ///
+ /// Get events of a specific type.
+ ///
+ public static List ByType(string eventType)
+ {
+ return Query()
+ .OfType(eventType)
+ .Execute();
+ }
+
+ ///
+ /// Search events by text.
+ ///
+ public static List Search(string searchTerm)
+ {
+ return Query()
+ .WithSearchTerm(searchTerm)
+ .Execute();
+ }
+
+ ///
+ /// Get the most recent N events.
+ ///
+ public static List Latest(int count = 50)
+ {
+ return Query()
+ .OrderBy(QuerySortOrder.NewestFirst)
+ .Limit(count)
+ .Execute();
+ }
+ }
+
+ ///
+ /// Extension methods for IEnumerable to enable post-query filtering.
+ ///
+ public static class EventQueryExtensions
+ {
+ ///
+ /// Filter to events of specific types.
+ ///
+ public static IEnumerable OfTypes(this IEnumerable source, params string[] types)
+ {
+ var typeSet = new HashSet(types);
+ return source.Where(e => typeSet.Contains(e.Type));
+ }
+
+ ///
+ /// Filter to events within a time range.
+ ///
+ public static IEnumerable InRange(this IEnumerable source, long startMs, long endMs)
+ {
+ return source.Where(e => e.TimestampUnixMs >= startMs && e.TimestampUnixMs <= endMs);
+ }
+
+ ///
+ /// Filter to recent events within N minutes.
+ ///
+ public static IEnumerable Recent(this IEnumerable source, int minutes)
+ {
+ long threshold = DateTimeOffset.UtcNow.AddMinutes(-minutes).ToUnixTimeMilliseconds();
+ return source.Where(e => e.TimestampUnixMs >= threshold);
+ }
+
+ ///
+ /// Filter to events for a specific target.
+ ///
+ public static IEnumerable ForTarget(this IEnumerable source, string targetId)
+ {
+ return source.Where(e => e.TargetId == targetId);
+ }
+
+ ///
+ /// Sort events by timestamp (newest first).
+ ///
+ public static IEnumerable NewestFirst(this IEnumerable source)
+ {
+ return source.OrderByDescending(e => e.TimestampUnixMs);
+ }
+
+ ///
+ /// Sort events by timestamp (oldest first).
+ ///
+ public static IEnumerable OldestFirst(this IEnumerable source)
+ {
+ return source.OrderBy(e => e.TimestampUnixMs);
+ }
+
+ ///
+ /// Get unique target IDs from events.
+ ///
+ public static IEnumerable UniqueTargets(this IEnumerable source)
+ {
+ return source.Select(e => e.TargetId)
+ .Where(id => !string.IsNullOrEmpty(id))
+ .Distinct();
+ }
+
+ ///
+ /// Group events by target ID.
+ ///
+ public static IEnumerable> GroupByTarget(this IEnumerable source)
+ {
+ return source.Where(e => !string.IsNullOrEmpty(e.TargetId))
+ .GroupBy(e => e.TargetId);
+ }
+
+ ///
+ /// Group events by type.
+ ///
+ public static IEnumerable> GroupByType(this IEnumerable source)
+ {
+ return source.GroupBy(e => e.Type ?? "Unknown");
+ }
+
+ ///
+ /// Convert to list (convenience method).
+ ///
+ public static List ToList(this IEnumerable source)
+ {
+ return new List(source);
+ }
+ }
+}
diff --git a/MCPForUnity/Editor/ActionTrace/Analysis/Summarization/EventSummarizer.cs b/MCPForUnity/Editor/ActionTrace/Analysis/Summarization/EventSummarizer.cs
new file mode 100644
index 000000000..0d8e9929f
--- /dev/null
+++ b/MCPForUnity/Editor/ActionTrace/Analysis/Summarization/EventSummarizer.cs
@@ -0,0 +1,483 @@
+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) ?? "");
+ sb.Replace("{target_id}", evt.TargetId ?? "");
+ sb.Replace("{time}", FormatTime(evt.TimestampUnixMs));
+ sb.Replace("{property_path_no_m}", StripMPrefix(evt, "property_path"));
+ sb.Replace("{start_value_readable}", GetReadableValue(evt, "start_value"));
+ sb.Replace("{end_value_readable}", GetReadableValue(evt, "end_value"));
+
+ return sb.ToString();
+ }
+
+ ///
+ /// Process {if:key, then, else} conditionals with else branch.
+ ///
+ private static string ProcessIfElse(string template, EditorEvent evt)
+ {
+ return IfElsePattern.Replace(template, match =>
+ {
+ string key = match.Groups[1].Value.Trim();
+ string thenText = match.Groups[2].Value.Trim();
+ string elseText = match.Groups[3].Value.Trim();
+ return HasMeaningfulValue(evt, key) ? thenText : elseText;
+ });
+ }
+
+ ///
+ /// Process {if_any:key1,key2, then} - true if ANY key has meaningful value.
+ ///
+ private static string ProcessIfAny(string template, EditorEvent evt)
+ {
+ return IfAnyPattern.Replace(template, match =>
+ {
+ string keys = match.Groups[1].Value;
+ string thenText = match.Groups[2].Value.Trim();
+ string[] keyList = keys.Split(',');
+
+ foreach (string key in keyList)
+ {
+ if (HasMeaningfulValue(evt, key.Trim()))
+ return thenText;
+ }
+ return "";
+ });
+ }
+
+ ///
+ /// Process {if_all:key1,key2, then} - true only if ALL keys have meaningful values.
+ ///
+ private static string ProcessIfAll(string template, EditorEvent evt)
+ {
+ return IfAllPattern.Replace(template, match =>
+ {
+ string keys = match.Groups[1].Value;
+ string thenText = match.Groups[2].Value.Trim();
+ string[] keyList = keys.Split(',');
+
+ foreach (string key in keyList)
+ {
+ if (!HasMeaningfulValue(evt, key.Trim()))
+ return "";
+ }
+ return thenText;
+ });
+ }
+
+ ///
+ /// Process simple {if:key, then} conditionals (without else).
+ /// Done after if_else to avoid double-processing.
+ ///
+ private static string ProcessSimpleIf(string template, EditorEvent evt)
+ {
+ return IfPattern.Replace(template, match =>
+ {
+ // Skip if this looks like part of an already-processed pattern
+ if (match.Value.Contains(",,")) return match.Value;
+
+ string key = match.Groups[1].Value.Trim();
+ string thenText = match.Groups[2].Value.Trim();
+ return HasMeaningfulValue(evt, key) ? thenText : "";
+ });
+ }
+
+ ///
+ /// Process {eq:key, value, then} - insert 'then' if key equals value.
+ ///
+ private static string ProcessEq(string template, EditorEvent evt)
+ {
+ return EqPattern.Replace(template, match =>
+ {
+ string key = match.Groups[1].Value.Trim();
+ string expectedValue = match.Groups[2].Value.Trim();
+ string thenText = match.Groups[3].Value.Trim();
+
+ string actualValue = GetPayloadStringValue(evt, key);
+ return string.Equals(actualValue, expectedValue, StringComparison.Ordinal) ? thenText : "";
+ });
+ }
+
+ ///
+ /// Process {ne:key, value, then} - insert 'then' if key does not equal value.
+ ///
+ private static string ProcessNe(string template, EditorEvent evt)
+ {
+ return NePattern.Replace(template, match =>
+ {
+ string key = match.Groups[1].Value.Trim();
+ string expectedValue = match.Groups[2].Value.Trim();
+ string thenText = match.Groups[3].Value.Trim();
+
+ string actualValue = GetPayloadStringValue(evt, key);
+ return !string.Equals(actualValue, expectedValue, StringComparison.Ordinal) ? thenText : "";
+ });
+ }
+
+ ///
+ /// Process {format:key, format} - format key value.
+ /// Supported formats: upper, lower, trim, truncate:N, capitalize
+ ///
+ private static string ProcessFormat(string template, EditorEvent evt)
+ {
+ return FormatPattern.Replace(template, match =>
+ {
+ string key = match.Groups[1].Value.Trim();
+ string format = match.Groups[2].Value.Trim();
+
+ string value = GetPayloadStringValue(evt, key);
+ if (string.IsNullOrEmpty(value))
+ return "";
+
+ return format switch
+ {
+ "upper" => value.ToUpperInvariant(),
+ "lower" => value.ToLowerInvariant(),
+ "trim" => value.Trim(),
+ "capitalize" => Capitalize(value),
+ _ when format.StartsWith("truncate:") => Truncate(value, ParseInt(format, DefaultTruncateLength)),
+ _ => value
+ };
+ });
+ }
+
+ ///
+ /// Gets a string value from payload, or 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:{target_id})"
+ /// Strips "m_" prefix from Unity serialized property names.
+ /// Includes GameObject ID for AI tool invocation.
+ ///
+ 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 ID and (selected) for AI tool invocation
+ if (string.IsNullOrEmpty(evt.TargetId))
+ return baseSummary + (isSelection ? " (selected)" : "");
+
+ return isSelection
+ ? $"{baseSummary} (selected, GameObject:{evt.TargetId})"
+ : $"{baseSummary} (GameObject:{evt.TargetId})";
+ }
+
+ ///
+ /// 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 TargetId if none found.
+ ///
+ private static string GetTargetName(EditorEvent evt)
+ {
+ // Try to get a human-readable name from payload
+ if (evt.Payload != null)
+ {
+ if (evt.Payload.TryGetValue("name", out var name) && 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 target ID
+ return evt.TargetId ?? "";
+ }
+
+ ///
+ /// Format Unix timestamp to HH:mm:ss time string.
+ ///
+ private static string FormatTime(long timestampMs)
+ {
+ var dt = DateTimeOffset.FromUnixTimeMilliseconds(timestampMs).ToLocalTime();
+ return dt.ToString("HH:mm:ss");
+ }
+ }
+}
diff --git a/MCPForUnity/Editor/ActionTrace/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/CapturePoints/BuiltInCapturePoints.cs b/MCPForUnity/Editor/ActionTrace/Capture/CapturePoints/BuiltInCapturePoints.cs
new file mode 100644
index 000000000..2b9d08237
--- /dev/null
+++ b/MCPForUnity/Editor/ActionTrace/Capture/CapturePoints/BuiltInCapturePoints.cs
@@ -0,0 +1,17 @@
+namespace MCPForUnity.Editor.ActionTrace.Capture
+{
+ ///
+ /// Built-in capture point identifiers.
+ ///
+ public static class BuiltInCapturePoints
+ {
+ public const string UnityCallbacks = "UnityCallbacks";
+ public const string AssetPostprocessor = "AssetPostprocessor";
+ public const string PropertyTracking = "PropertyTracking";
+ public const string SelectionTracking = "SelectionTracking";
+ public const string HierarchyTracking = "HierarchyTracking";
+ public const string BuildTracking = "BuildTracking";
+ public const string CompilationTracking = "CompilationTracking";
+ public const string ToolInvocation = "ToolInvocation";
+ }
+}
diff --git a/MCPForUnity/Editor/ActionTrace/Capture/CapturePoints/CapturePointStats.cs b/MCPForUnity/Editor/ActionTrace/Capture/CapturePoints/CapturePointStats.cs
new file mode 100644
index 000000000..9cdd01893
--- /dev/null
+++ b/MCPForUnity/Editor/ActionTrace/Capture/CapturePoints/CapturePointStats.cs
@@ -0,0 +1,65 @@
+using System;
+
+namespace MCPForUnity.Editor.ActionTrace.Capture
+{
+ ///
+ /// Statistics for a capture point.
+ ///
+ [Serializable]
+ public sealed class CapturePointStats
+ {
+ public int TotalEventsCaptured;
+ public int EventsFiltered;
+ public int EventsSampled;
+ public long TotalCaptureTimeMs;
+ public double AverageCaptureTimeMs;
+ public int ErrorCount;
+
+ private long _startTimeTicks;
+
+ public void StartCapture()
+ {
+ _startTimeTicks = DateTimeOffset.UtcNow.Ticks;
+ }
+
+ public void EndCapture()
+ {
+ long elapsedTicks = DateTimeOffset.UtcNow.Ticks - _startTimeTicks;
+ TotalCaptureTimeMs += elapsedTicks / 10000;
+ TotalEventsCaptured++;
+ UpdateAverage();
+ }
+
+ public void RecordFiltered()
+ {
+ EventsFiltered++;
+ }
+
+ public void RecordSampled()
+ {
+ EventsSampled++;
+ }
+
+ public void RecordError()
+ {
+ ErrorCount++;
+ }
+
+ public void UpdateAverage()
+ {
+ AverageCaptureTimeMs = TotalEventsCaptured > 0
+ ? (double)TotalCaptureTimeMs / TotalEventsCaptured
+ : 0;
+ }
+
+ public void Reset()
+ {
+ TotalEventsCaptured = 0;
+ EventsFiltered = 0;
+ EventsSampled = 0;
+ TotalCaptureTimeMs = 0;
+ AverageCaptureTimeMs = 0;
+ ErrorCount = 0;
+ }
+ }
+}
diff --git a/MCPForUnity/Editor/ActionTrace/Capture/CapturePoints/EventCapturePointAttribute.cs b/MCPForUnity/Editor/ActionTrace/Capture/CapturePoints/EventCapturePointAttribute.cs
new file mode 100644
index 000000000..f9e1b43f1
--- /dev/null
+++ b/MCPForUnity/Editor/ActionTrace/Capture/CapturePoints/EventCapturePointAttribute.cs
@@ -0,0 +1,23 @@
+using System;
+
+namespace MCPForUnity.Editor.ActionTrace.Capture
+{
+ ///
+ /// Attribute to mark a class as an event capture point.
+ /// Used for auto-discovery during initialization.
+ ///
+ [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
+ public sealed class EventCapturePointAttribute : Attribute
+ {
+ public string Id { get; }
+ public string Description { get; }
+ public int Priority { get; }
+
+ public EventCapturePointAttribute(string id, string description = null, int priority = 0)
+ {
+ Id = id;
+ Description = description ?? id;
+ Priority = priority;
+ }
+ }
+}
diff --git a/MCPForUnity/Editor/ActionTrace/Capture/CapturePoints/EventCapturePointBase.cs b/MCPForUnity/Editor/ActionTrace/Capture/CapturePoints/EventCapturePointBase.cs
new file mode 100644
index 000000000..3c6b17bf9
--- /dev/null
+++ b/MCPForUnity/Editor/ActionTrace/Capture/CapturePoints/EventCapturePointBase.cs
@@ -0,0 +1,88 @@
+using System;
+using MCPForUnity.Editor.ActionTrace.Context;
+using MCPForUnity.Editor.ActionTrace.Core.Models;
+using MCPForUnity.Editor.ActionTrace.Core.Store;
+using UnityEngine;
+
+namespace MCPForUnity.Editor.ActionTrace.Capture
+{
+ ///
+ /// Base class for capture points with common functionality.
+ ///
+ public abstract class EventCapturePointBase : IEventCapturePoint
+ {
+ private readonly CapturePointStats _stats = new();
+ private bool _isEnabled = true;
+
+ public abstract string CapturePointId { get; }
+ public abstract string Description { get; }
+ public virtual int InitializationPriority => 0;
+
+ public virtual bool IsEnabled
+ {
+ get => _isEnabled;
+ set => _isEnabled = value;
+ }
+
+ public virtual void Initialize() { }
+ public virtual void Shutdown() { }
+
+ public virtual string GetDiagnosticInfo()
+ {
+ return $"[{CapturePointId}] {Description}\n" +
+ $" Enabled: {IsEnabled}\n" +
+ $" Events: {_stats.TotalEventsCaptured} captured, {_stats.EventsFiltered} filtered, {_stats.EventsSampled} sampled\n" +
+ $" Avg Capture Time: {_stats.AverageCaptureTimeMs:F3}ms\n" +
+ $" Errors: {_stats.ErrorCount}";
+ }
+
+ public virtual CapturePointStats GetStats() => _stats;
+
+ ///
+ /// Record an event through the capture pipeline.
+ /// This method handles filtering, sampling, and storage.
+ ///
+ protected void RecordEvent(EditorEvent evt, ContextMapping context = null)
+ {
+ if (!IsEnabled) return;
+
+ _stats.StartCapture();
+
+ try
+ {
+ // Create event and record via EventStore
+ EventStore.Record(evt);
+ _stats.EndCapture();
+ }
+ catch (Exception ex)
+ {
+ _stats.RecordError();
+ Debug.LogError($"[{CapturePointId}] Error recording event: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Record a filtered event (doesn't count towards captured stats).
+ ///
+ protected void RecordFiltered()
+ {
+ _stats.RecordFiltered();
+ }
+
+ ///
+ /// Record a sampled event (counted as sampled, not captured).
+ ///
+ protected void RecordSampled()
+ {
+ _stats.RecordSampled();
+ }
+
+ ///
+ /// Reset statistics.
+ ///
+ public void ResetStats()
+ {
+ _stats.Reset();
+ }
+ }
+}
diff --git a/MCPForUnity/Editor/ActionTrace/Capture/CapturePoints/EventCaptureRegistry.cs b/MCPForUnity/Editor/ActionTrace/Capture/CapturePoints/EventCaptureRegistry.cs
new file mode 100644
index 000000000..e60c50a67
--- /dev/null
+++ b/MCPForUnity/Editor/ActionTrace/Capture/CapturePoints/EventCaptureRegistry.cs
@@ -0,0 +1,186 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+using UnityEngine;
+
+namespace MCPForUnity.Editor.ActionTrace.Capture
+{
+ ///
+ /// Registry for all event capture points.
+ /// Manages lifecycle and provides access for diagnostics.
+ ///
+ public sealed class EventCaptureRegistry
+ {
+ private static readonly Lazy _instance =
+ new(() => new EventCaptureRegistry());
+
+ private readonly List _capturePoints = new();
+ private bool _isInitialized;
+
+ public static EventCaptureRegistry Instance => _instance.Value;
+
+ private EventCaptureRegistry() { }
+
+ ///
+ /// Register a capture point.
+ /// Should be called during initialization, before Start().
+ ///
+ public void Register(IEventCapturePoint capturePoint)
+ {
+ if (capturePoint == null) return;
+
+ _capturePoints.Add(capturePoint);
+
+ // Sort by priority
+ _capturePoints.Sort((a, b) => b.InitializationPriority.CompareTo(a.InitializationPriority));
+ }
+
+ ///
+ /// Unregister a capture point.
+ ///
+ public bool Unregister(string capturePointId)
+ {
+ var point = _capturePoints.Find(p => p.CapturePointId == capturePointId);
+ if (point != null)
+ {
+ if (_isInitialized)
+ {
+ try
+ {
+ point.Shutdown();
+ }
+ catch (Exception ex)
+ {
+ Debug.LogError($"[EventCaptureRegistry] Failed to shutdown {point.CapturePointId}: {ex.Message}");
+ }
+ }
+ _capturePoints.Remove(point);
+ return true;
+ }
+ return false;
+ }
+
+ ///
+ /// Initialize all registered capture points.
+ ///
+ public void InitializeAll()
+ {
+ if (_isInitialized) return;
+
+ foreach (var point in _capturePoints)
+ {
+ try
+ {
+ point.Initialize();
+ }
+ catch (Exception ex)
+ {
+ Debug.LogError($"[EventCaptureRegistry] Failed to initialize {point.CapturePointId}: {ex.Message}");
+ }
+ }
+
+ _isInitialized = true;
+ Debug.Log($"[EventCaptureRegistry] Initialized {_capturePoints.Count} capture points");
+ }
+
+ ///
+ /// Shutdown all registered capture points.
+ ///
+ public void ShutdownAll()
+ {
+ if (!_isInitialized) return;
+
+ // Shutdown in reverse order
+ for (int i = _capturePoints.Count - 1; i >= 0; i--)
+ {
+ try
+ {
+ _capturePoints[i].Shutdown();
+ }
+ catch (Exception ex)
+ {
+ Debug.LogError($"[EventCaptureRegistry] Failed to shutdown {_capturePoints[i].CapturePointId}: {ex.Message}");
+ }
+ }
+
+ _isInitialized = false;
+ }
+
+ ///
+ /// Get a capture point by ID.
+ ///
+ public IEventCapturePoint GetCapturePoint(string id)
+ {
+ return _capturePoints.Find(p => p.CapturePointId == id);
+ }
+
+ ///
+ /// Get all registered capture points.
+ ///
+ public IReadOnlyList GetAllCapturePoints()
+ {
+ return _capturePoints.AsReadOnly();
+ }
+
+ ///
+ /// Get enabled capture points.
+ ///
+ public IReadOnlyList GetEnabledCapturePoints()
+ {
+ return _capturePoints.FindAll(p => p.IsEnabled).AsReadOnly();
+ }
+
+ ///
+ /// Get diagnostic information for all capture points.
+ ///
+ public string GetDiagnosticInfo()
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine($"Event Capture Registry - {_capturePoints.Count} points registered:");
+ sb.AppendLine($"Initialized: {_isInitialized}");
+
+ foreach (var point in _capturePoints)
+ {
+ sb.AppendLine();
+ sb.AppendLine(point.GetDiagnosticInfo());
+ }
+
+ return sb.ToString();
+ }
+
+ ///
+ /// Get aggregated statistics from all capture points.
+ ///
+ public CapturePointStats GetAggregatedStats()
+ {
+ var aggregated = new CapturePointStats();
+
+ foreach (var point in _capturePoints)
+ {
+ var stats = point.GetStats();
+ aggregated.TotalEventsCaptured += stats.TotalEventsCaptured;
+ aggregated.EventsFiltered += stats.EventsFiltered;
+ aggregated.EventsSampled += stats.EventsSampled;
+ aggregated.TotalCaptureTimeMs += stats.TotalCaptureTimeMs;
+ aggregated.ErrorCount += stats.ErrorCount;
+ }
+
+ aggregated.UpdateAverage();
+ return aggregated;
+ }
+
+ ///
+ /// Enable or disable a capture point by ID.
+ ///
+ public bool SetEnabled(string id, bool enabled)
+ {
+ var point = GetCapturePoint(id);
+ if (point != null)
+ {
+ point.IsEnabled = enabled;
+ return true;
+ }
+ return false;
+ }
+ }
+}
diff --git a/MCPForUnity/Editor/ActionTrace/Capture/CapturePoints/IEventCapturePoint.cs b/MCPForUnity/Editor/ActionTrace/Capture/CapturePoints/IEventCapturePoint.cs
new file mode 100644
index 000000000..09e5115cb
--- /dev/null
+++ b/MCPForUnity/Editor/ActionTrace/Capture/CapturePoints/IEventCapturePoint.cs
@@ -0,0 +1,62 @@
+namespace MCPForUnity.Editor.ActionTrace.Capture
+{
+ ///
+ /// Defines a point in the editor where events can be captured.
+ ///
+ /// This interface unifies all event capture sources:
+ /// - Unity callbacks (EditorApplication events)
+ /// - Asset postprocessors
+ /// - Component change tracking
+ /// - Custom tool invocations
+ ///
+ /// Implementations should be lightweight and focus on event capture,
+ /// delegating filtering, sampling, and storage to the middleware pipeline.
+ ///
+ public interface IEventCapturePoint
+ {
+ ///
+ /// Unique identifier for this capture point.
+ /// Used for diagnostics and configuration.
+ ///
+ string CapturePointId { get; }
+
+ ///
+ /// Human-readable description of what this capture point monitors.
+ ///
+ string Description { get; }
+
+ ///
+ /// Priority for initialization (higher = earlier).
+ /// Useful for dependencies between capture points.
+ ///
+ int InitializationPriority { get; }
+
+ ///
+ /// Whether this capture point is currently enabled.
+ ///
+ bool IsEnabled { get; set; }
+
+ ///
+ /// Initialize the capture point.
+ /// Called when ActionTrace system starts.
+ ///
+ void Initialize();
+
+ ///
+ /// Shutdown the capture point.
+ /// Called when ActionTrace system stops or domain reloads.
+ ///
+ void Shutdown();
+
+ ///
+ /// Get diagnostic information about this capture point.
+ /// Useful for debugging and monitoring.
+ ///
+ string GetDiagnosticInfo();
+
+ ///
+ /// Get statistics about captured events.
+ ///
+ CapturePointStats GetStats();
+ }
+}
diff --git a/MCPForUnity/Editor/ActionTrace/Capture/Capturers/AssetCapture.cs b/MCPForUnity/Editor/ActionTrace/Capture/Capturers/AssetCapture.cs
new file mode 100644
index 000000000..3c2e26945
--- /dev/null
+++ b/MCPForUnity/Editor/ActionTrace/Capture/Capturers/AssetCapture.cs
@@ -0,0 +1,386 @@
+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();
+ }
+
+ // Inject Undo Group ID for undo_to_sequence functionality (P2.4)
+ int currentUndoGroup = Undo.GetCurrentGroup();
+ payload["undo_group"] = currentUndoGroup;
+
+ var evt = new EditorEvent(
+ sequence: 0,
+ timestampUnixMs: DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ type: type,
+ targetId: targetId,
+ payload: payload
+ );
+
+ // AssetPostprocessor callbacks run on main thread but outside update loop.
+ // Use delayCall to defer recording to main thread update, avoiding thread warnings.
+ UnityEditor.EditorApplication.delayCall += () => 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..a6e439544
--- /dev/null
+++ b/MCPForUnity/Editor/ActionTrace/Capture/Capturers/PropertyCapture.cs
@@ -0,0 +1,329 @@
+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;
+
+namespace MCPForUnity.Editor.ActionTrace.Capture
+{
+ ///
+ /// High-performance property change tracker with debouncing.
+ ///
+ /// Captures Unity property modifications via Undo.postprocessModifications,
+ /// applies debouncing to merge rapid changes (e.g., Slider drag), and records
+ /// PropertyModified events to the ActionTrace EventStore.
+ ///
+ /// Key features:
+ /// - Uses EditorApplication.update for periodic flushing (safe on domain reload)
+ /// - Object pooling to reduce GC pressure
+ /// - Cache size limits to prevent unbounded memory growth
+ /// - Cross-session stable IDs via GlobalIdHelper
+ ///
+ /// Reuses existing Helpers:
+ /// - GlobalIdHelper.ToGlobalIdString() for stable object IDs
+ /// - PropertyFormatter for property value formatting
+ /// - UndoReflectionHelper for Undo reflection logic
+ ///
+ [InitializeOnLoad]
+ public static class PropertyChangeTracker
+ {
+ // Configuration
+ private const long DebounceWindowMs = 500; // Debounce window in milliseconds
+ private const int MaxPendingEntries = 256; // Max pending changes before forced flush
+
+ // State
+ private static readonly 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.
+ ///
+ private static UndoPropertyModification[] ProcessModifications(UndoPropertyModification[] modifications)
+ {
+ if (modifications == null || modifications.Length == 0)
+ return modifications;
+
+ lock (_lock)
+ {
+ foreach (var undoMod in modifications)
+ {
+ // UndoPropertyModification contains the PropertyModification and value changes
+ // Try to extract target and property path
+ var target = UndoReflectionHelper.GetTarget(undoMod);
+ if (target == null)
+ continue;
+
+ var propertyPath = UndoReflectionHelper.GetPropertyPath(undoMod);
+ if (string.IsNullOrEmpty(propertyPath))
+ continue;
+
+ // Filter out Unity internal properties
+ if (PropertyFormatter.IsInternalProperty(propertyPath))
+ {
+ continue;
+ }
+
+ // Generate stable unique key
+ string globalId = GlobalIdHelper.ToGlobalIdString(target);
+ if (string.IsNullOrEmpty(globalId))
+ continue;
+
+ string uniqueKey = $"{globalId}:{propertyPath}";
+
+ // Get the current value (not the path)
+ var currentValue = UndoReflectionHelper.GetCurrentValue(undoMod);
+
+ // Check if we already have a pending change for this property
+ if (_pendingChanges.TryGetValue(uniqueKey, out var pending))
+ {
+ // Update existing pending change
+ // Note: Must reassign to dictionary since PendingPropertyChange is a struct
+ pending.EndValue = PropertyFormatter.FormatPropertyValue(currentValue);
+ pending.ChangeCount++;
+ pending.LastUpdateMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+ _pendingChanges[uniqueKey] = pending;
+ }
+ else
+ {
+ // Enforce cache limit to prevent unbounded growth
+ if (_pendingChanges.Count >= MaxPendingEntries)
+ {
+ // Force flush ALL entries to make room immediately
+ FlushPendingChanges(force: true);
+ }
+
+ // Create new pending change (use object pool if available)
+ var change = AcquirePendingChange();
+ change.GlobalId = globalId;
+ change.TargetName = target.name;
+ change.ComponentType = target.GetType().Name;
+ change.PropertyPath = propertyPath;
+ // Record the start value from the previous value reported by Undo system
+ var prev = UndoReflectionHelper.GetPreviousValue(undoMod);
+ change.StartValue = PropertyFormatter.FormatPropertyValue(prev);
+ change.EndValue = PropertyFormatter.FormatPropertyValue(currentValue);
+ change.PropertyType = PropertyFormatter.GetPropertyTypeName(currentValue);
+ change.ChangeCount = 1;
+ change.LastUpdateMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+
+ _pendingChanges[uniqueKey] = change;
+ }
+ }
+
+ return modifications;
+ }
+ }
+
+ ///
+ /// Flushes all pending property changes that have exceeded the debounce window.
+ /// Called periodically via EditorApplication.update.
+ ///
+ /// 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..584b88cd5
--- /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 = UndoReflectionHelper.GetTarget(undoMod);
+ if (target == null)
+ {
+ continue;
+ }
+
+ // Check if this modification targets the currently selected object or its components
+ string targetGlobalId = GlobalIdHelper.ToGlobalIdString(target);
+ bool isMatch = IsTargetMatchSelection(target, targetGlobalId);
+ // McpLog.Debug($"[SelectionPropertyTracker] targetId={targetGlobalId}, selectionId={_currentSelectionGlobalId}, match={isMatch}");
+ if (!isMatch)
+ continue;
+
+ var propertyPath = UndoReflectionHelper.GetPropertyPath(undoMod);
+ if (string.IsNullOrEmpty(propertyPath))
+ continue;
+
+ // Filter out Unity internal properties
+ if (PropertyFormatter.IsInternalProperty(propertyPath))
+ continue;
+
+ // Record the SelectionPropertyModified event
+ // McpLog.Debug($"[SelectionPropertyTracker] MATCH! Recording event for {target.name}.{propertyPath}");
+ RecordSelectionPropertyModified(undoMod, target, targetGlobalId, propertyPath);
+ }
+
+ return modifications;
+ }
+
+ ///
+ /// Records a SelectionPropertyModified event to the ActionTrace EventStore.
+ ///
+ private static void RecordSelectionPropertyModified(UndoPropertyModification undoMod, UnityEngine.Object target, string targetGlobalId, string propertyPath)
+ {
+ var currentValue = UndoReflectionHelper.GetCurrentValue(undoMod);
+ var prevValue = UndoReflectionHelper.GetPreviousValue(undoMod);
+
+ var payload = new Dictionary
+ {
+ ["target_name"] = target.name,
+ ["component_type"] = target.GetType().Name,
+ ["property_path"] = propertyPath,
+ ["start_value"] = PropertyFormatter.FormatPropertyValue(prevValue),
+ ["end_value"] = PropertyFormatter.FormatPropertyValue(currentValue),
+ ["value_type"] = PropertyFormatter.GetPropertyTypeName(currentValue),
+ ["selection_context"] = new Dictionary
+ {
+ ["selection_id"] = _currentSelectionGlobalId,
+ ["selection_name"] = _currentSelectionName,
+ ["selection_type"] = _currentSelectionType,
+ ["selection_path"] = _currentSelectionPath ?? string.Empty
+ }
+ };
+
+ var evt = new EditorEvent(
+ sequence: 0,
+ timestampUnixMs: DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ type: EventTypes.SelectionPropertyModified,
+ targetId: targetGlobalId,
+ payload: payload
+ );
+
+ EventStore.Record(evt);
+ }
+
+
+ ///
+ /// Checks if the modified target matches the current selection.
+ /// Handles both direct GameObject matches and Component-on-selected-GameObject matches.
+ ///
+ private static bool IsTargetMatchSelection(UnityEngine.Object target, string targetGlobalId)
+ {
+ // Direct match
+ if (targetGlobalId == _currentSelectionGlobalId)
+ return true;
+
+ // If target is a Component, check if its owner GameObject matches the selection
+ if (target is Component comp)
+ {
+ string gameObjectId = GlobalIdHelper.ToGlobalIdString(comp.gameObject);
+ if (gameObjectId == _currentSelectionGlobalId)
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Gets the full Hierarchy path for a GameObject.
+ /// Example: "Level1/Player/Arm/Hand"
+ ///
+ private static string GetGameObjectPath(GameObject obj)
+ {
+ if (obj == null)
+ return "Unknown";
+
+ var path = obj.name;
+ var parent = obj.transform.parent;
+
+ while (parent != null)
+ {
+ path = $"{parent.name}/{path}";
+ parent = parent.parent;
+ }
+
+ return path;
+ }
+ }
+}
diff --git a/MCPForUnity/Editor/ActionTrace/Capture/Capturers/UndoCapture.cs b/MCPForUnity/Editor/ActionTrace/Capture/Capturers/UndoCapture.cs
new file mode 100644
index 000000000..a57faed4e
--- /dev/null
+++ b/MCPForUnity/Editor/ActionTrace/Capture/Capturers/UndoCapture.cs
@@ -0,0 +1,195 @@
+using System;
+using UnityEditor;
+using MCPForUnity.Editor.Helpers;
+using MCPForUnity.Editor.ActionTrace.Helpers;
+
+namespace MCPForUnity.Editor.ActionTrace.Capture
+{
+ ///
+ /// Manages Unity Undo grouping for AI tool calls.
+ ///
+ /// Purpose:
+ /// - Groups multiple Undo operations into a single logical transaction
+ /// - Enables one Ctrl+Z to undo an entire AI tool call
+ /// - Works with TransactionAggregator to provide atomic operation semantics
+ ///
+ /// Usage (from ActionTrace-enhancements.md line 320-336):
+ /// UndoGroupManager.BeginToolCall("manage_gameobject", "abc123");
+ /// // ... perform operations ...
+ /// UndoGroupManager.EndToolCall();
+ ///
+ /// Integration with add_ActionTrace_note:
+ /// - When AI adds a note with is_transaction_end=true,
+ /// automatically collapses Undo operations since BeginToolCall
+ ///
+ /// Architecture notes:
+ /// - This is an optional enhancement for better UX
+ /// - Does not affect ActionTrace event recording
+ /// - Independent of OperationContext (tracks Undo state, not tool context)
+ ///
+ public static class UndoGroupManager
+ {
+ // State tracking
+ private static string _currentToolName;
+ private static string _currentToolCallId;
+ private static int _currentUndoGroupStart = -1;
+ private static bool _isInToolCall = false;
+
+ ///
+ /// Starts a new Undo group for a tool call.
+ ///
+ /// Call this at the beginning of an AI tool operation.
+ /// All subsequent Undo operations will be grouped under this name.
+ ///
+ /// Parameters:
+ /// toolName: Name of the tool (e.g., "manage_gameobject")
+ /// toolCallId: Unique identifier for this tool call (UUID)
+ ///
+ /// Example:
+ /// UndoGroupManager.BeginToolCall("manage_gameobject", "abc-123-def");
+ ///
+ public static void BeginToolCall(string toolName, string toolCallId)
+ {
+ // Guard against nested BeginToolCall invocations
+ if (_isInToolCall)
+ {
+ McpLog.Warn($"[UndoGroupManager] BeginToolCall called while already in tool call '{_currentToolName}' (id: {_currentToolCallId}). Aborting previous state.");
+ AbortToolCall();
+ }
+
+ if (string.IsNullOrEmpty(toolName))
+ {
+ McpLog.Warn("[UndoGroupManager] BeginToolCall called with null toolName");
+ toolName = "AI Operation";
+ }
+
+ // Set the current Undo group name
+ // This name will appear in the Undo history (e.g., "Ctrl+Z AI: Create Player")
+ Undo.SetCurrentGroupName($"AI: {ActionTraceHelper.FormatToolName(toolName)}");
+
+ // Record the group start position for later collapsing
+ _currentUndoGroupStart = Undo.GetCurrentGroup();
+ _currentToolName = toolName;
+ _currentToolCallId = toolCallId;
+ _isInToolCall = true;
+
+ McpLog.Info($"[UndoGroupManager] BeginToolCall: {toolName} (group {_currentUndoGroupStart})");
+ }
+
+ ///
+ /// Ends the current Undo group and collapses all operations.
+ ///
+ /// Call this at the end of an AI tool operation.
+ /// All Undo operations since BeginToolCall will be merged into one.
+ ///
+ /// Example:
+ /// UndoGroupManager.EndToolCall();
+ ///
+ /// After this, user can press Ctrl+Z once to undo the entire tool call.
+ ///
+ public static void EndToolCall()
+ {
+ if (!_isInToolCall)
+ {
+ McpLog.Warn("[UndoGroupManager] EndToolCall called without matching BeginToolCall");
+ return;
+ }
+
+ if (_currentUndoGroupStart >= 0)
+ {
+ // Collapse all Undo operations since BeginToolCall into one group
+ Undo.CollapseUndoOperations(_currentUndoGroupStart);
+
+ McpLog.Info($"[UndoGroupManager] EndToolCall: {_currentToolName} (collapsed from group {_currentUndoGroupStart})");
+ }
+
+ // Reset state
+ _currentToolName = null;
+ _currentToolCallId = null;
+ _currentUndoGroupStart = -1;
+ _isInToolCall = false;
+ }
+
+ ///
+ /// Checks if currently in a tool call.
+ ///
+ public static bool IsInToolCall => _isInToolCall;
+
+ ///
+ /// Gets the current tool name (if in a tool call).
+ /// Returns null if not in a tool call.
+ ///
+ public static string CurrentToolName => _currentToolName;
+
+ ///
+ /// Gets the current tool call ID (if in a tool call).
+ /// Returns null if not in a tool call.
+ ///
+ public static string CurrentToolCallId => _currentToolCallId;
+
+ ///
+ /// Gets the current Undo group start position.
+ /// Returns -1 if not in a tool call.
+ ///
+ public static int CurrentUndoGroupStart => _currentUndoGroupStart;
+
+ ///
+ /// Clears the current tool call state without collapsing.
+ ///
+ /// Use this for error recovery when a tool call fails partway through.
+ /// Does NOT collapse Undo operations (unlike EndToolCall).
+ ///
+ public static void AbortToolCall()
+ {
+ if (!_isInToolCall)
+ return;
+
+ McpLog.Warn($"[UndoGroupManager] AbortToolCall: {_currentToolName} (group {_currentUndoGroupStart})");
+
+ // Reset state without collapsing
+ _currentToolName = null;
+ _currentToolCallId = null;
+ _currentUndoGroupStart = -1;
+ _isInToolCall = false;
+ }
+
+ ///
+ /// Integration with add_ActionTrace_note.
+ ///
+ /// When AI adds a note with is_transaction_end=true,
+ /// automatically end the current Undo group.
+ ///
+ /// This allows the AI to mark completion of a logical transaction.
+ ///
+ /// Parameters:
+ /// note: The note text (will be used as Undo group name if in tool call)
+ /// isTransactionEnd: If true, calls EndToolCall()
+ ///
+ /// Returns:
+ /// The Undo group name that was set (or current group name if not ending)
+ ///
+ public static string HandleActionTraceNote(string note, bool isTransactionEnd)
+ {
+ string groupName;
+
+ if (_isInToolCall)
+ {
+ // Use the AI note as the final Undo group name
+ groupName = $"AI: {note}";
+ Undo.SetCurrentGroupName(groupName);
+
+ if (isTransactionEnd)
+ {
+ EndToolCall();
+ }
+
+ return groupName;
+ }
+
+ // Not in a tool call - just set the Undo name
+ groupName = $"AI: {note}";
+ Undo.SetCurrentGroupName(groupName);
+ return groupName;
+ }
+ }
+}
diff --git a/MCPForUnity/Editor/ActionTrace/Capture/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..0d4415140
--- /dev/null
+++ b/MCPForUnity/Editor/ActionTrace/Capture/Recorder.cs
@@ -0,0 +1,355 @@
+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.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.
+ ///
+ [InitializeOnLoad]
+ internal static class ActionTraceRecorder
+ {
+ static ActionTraceRecorder()
+ {
+ // 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()
+ {
+ // 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)
+ {
+ if (Selection.activeObject == null) return;
+
+ var selected = Selection.activeObject;
+ var payload = new Dictionary
+ {
+ ["name"] = selected.name,
+ ["type"] = selected.GetType().Name,
+ ["instance_id"] = selected.GetInstanceID()
+ };
+
+ if (selected is GameObject go)
+ {
+ payload["path"] = GetGameObjectPath(go);
+ }
+ else if (selected is Component comp)
+ {
+ payload["path"] = GetGameObjectPath(comp.gameObject);
+ payload["component_type"] = comp.GetType().Name;
+ }
+
+ string globalId = GlobalIdHelper.ToGlobalIdString(selected);
+ 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();
+ }
+
+ // Inject Undo Group ID
+ payload["undo_group"] = Undo.GetCurrentGroup();
+
+ // 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..a64338fff
--- /dev/null
+++ b/MCPForUnity/Editor/ActionTrace/Capture/Sampling/SamplingConfig.cs
@@ -0,0 +1,80 @@
+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.
+ ///
+ public static class SamplingConfig
+ {
+ ///
+ /// 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(
+ new Dictionary
+ {
+ // Hierarchy changes: Throttle to 1 event per second
+ {
+ EventTypes.HierarchyChanged,
+ new SamplingStrategy(SamplingMode.Throttle, 1000)
+ },
+
+ // PropertyModified handling removed here to avoid double-debounce when
+ // PropertyChangeTracker already implements a dedicated debounce window.
+ // If desired, SamplingConfig.SetStrategy(EventTypes.PropertyModified, ...) can
+ // be used at runtime to re-enable middleware-level sampling.
+
+ // Component/GameObject events: No sampling (always record)
+ // ComponentAdded, ComponentRemoved, GameObjectCreated, GameObjectDestroyed
+ // are intentionally not in this dictionary, so they default to None
+
+ // Play mode changes: No sampling (record all)
+ // PlayModeChanged is not in this dictionary
+
+ // Scene events: No sampling (record all)
+ // SceneSaving, SceneSaved, SceneOpened, NewSceneCreated are not in this dictionary
+
+ // Build events: No sampling (record all)
+ // BuildStarted, BuildCompleted, BuildFailed are not in this dictionary
+ }
+ );
+
+ ///
+ /// Adds or updates a sampling strategy for an event type.
+ ///
+ public static void SetStrategy(string eventType, SamplingMode mode, long windowMs = 1000)
+ {
+ Strategies[eventType] = new SamplingStrategy(mode, windowMs);
+ }
+
+ ///
+ /// Removes the sampling strategy for an event type (reverts to None).
+ ///
+ public static void RemoveStrategy(string eventType)
+ {
+ Strategies.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..7c5eadfe9
--- /dev/null
+++ b/MCPForUnity/Editor/ActionTrace/Capture/Sampling/SamplingMiddleware.cs
@@ -0,0 +1,348 @@
+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
+ private const int MaxSampleCache = 128; // Max pending samples before forced cleanup
+ private const long CleanupAgeMs = 2000; // Cleanup samples older than 2 seconds
+ private const long FlushCheckIntervalMs = 200; // Check for expired debounce samples every 200ms
+
+ // State
+ // Thread-safe dictionary to prevent race conditions in multi-threaded scenarios
+ private static readonly ConcurrentDictionary _pendingSamples = new();
+ private static long _lastCleanupTime;
+ private static long _lastFlushCheckTime;
+
+ ///
+ /// Initializes the sampling middleware and schedules periodic flush checks.
+ ///
+ static SamplingMiddleware()
+ {
+ ScheduleFlushCheck();
+ }
+
+ ///
+ /// Schedules a periodic flush check using EditorApplication.update.
+ /// This ensures Debounce modes emit trailing events after their windows expire.
+ /// Using update instead of delayCall to avoid infinite recursion.
+ ///
+ private static void ScheduleFlushCheck()
+ {
+ // Use EditorApplication.update instead of delayCall to avoid infinite recursion
+ // This ensures the callback is properly cleaned up on domain reload
+ EditorApplication.update -= FlushExpiredDebounceSamples;
+ EditorApplication.update += FlushExpiredDebounceSamples;
+ }
+
+ ///
+ /// Flushes debounce samples whose windows have expired.
+ /// This ensures Debounce/DebounceByKey modes emit the trailing event.
+ ///
+ private static void FlushExpiredDebounceSamples()
+ {
+ long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+
+ // Only check periodically to avoid performance impact
+ if (nowMs - _lastFlushCheckTime < FlushCheckIntervalMs)
+ return;
+
+ _lastFlushCheckTime = nowMs;
+
+ var toRecord = new List();
+
+ // Directly remove expired entries without intermediate list
+ foreach (var kvp in _pendingSamples)
+ {
+ // Check if this key has a debounce strategy configured
+ if (SamplingConfig.Strategies.TryGetValue(kvp.Value.Event.Type, out var strategy))
+ {
+ // Only process Debounce/DebounceByKey modes
+ if (strategy.Mode == SamplingMode.Debounce || strategy.Mode == SamplingMode.DebounceByKey)
+ {
+ // If window has expired, this sample should be recorded
+ if (nowMs - kvp.Value.TimestampMs > strategy.WindowMs)
+ {
+ toRecord.Add(kvp.Value);
+ // Remove immediately while iterating (TryRemove is safe)
+ _pendingSamples.TryRemove(kvp.Key, out _);
+ }
+ }
+ }
+ }
+
+ // Record the trailing events
+ foreach (var sample in toRecord)
+ {
+ // Record directly to EventStore without going through ShouldRecord again
+ EventStore.Record(sample.Event);
+ }
+ }
+
+ ///
+ /// Determines whether an event should be recorded based on configured sampling strategies.
+ /// Returns true if the event should be recorded, false if it should be filtered out.
+ ///
+ /// This method is called by event emitters before recording to EventStore.
+ /// Implements a three-stage filtering pipeline:
+ /// 1. Blacklist (EventFilter) - filters system junk
+ /// 2. Sampling strategy - merges duplicate events
+ /// 3. Cache management - prevents unbounded growth
+ ///
+ public static bool ShouldRecord(EditorEvent evt)
+ {
+ if (evt == null)
+ return false;
+
+ // ========== Stage 1: Blacklist Filtering (L1) ==========
+ // Check if this event's target is known junk before any other processing
+ if (evt.Type == EventTypes.AssetImported ||
+ evt.Type == EventTypes.AssetMoved ||
+ evt.Type == EventTypes.AssetDeleted)
+ {
+ // For asset events, check the path (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;
+ foreach (var kvp in _pendingSamples)
+ {
+ if (kvp.Value.TimestampMs < oldestTimestamp)
+ {
+ oldestTimestamp = kvp.Value.TimestampMs;
+ oldestKey = kvp.Key;
+ }
+ }
+ if (!string.IsNullOrEmpty(oldestKey))
+ {
+ _pendingSamples.TryRemove(oldestKey, out _);
+ }
+ }
+ }
+
+ // Add new pending sample
+ _pendingSamples[key] = new PendingSample
+ {
+ Event = evt,
+ TimestampMs = nowMs
+ };
+
+ // For Debounce modes, don't record immediately - wait for window to expire
+ // This prevents duplicate recording: first event here, trailing event in FlushExpiredDebounceSamples
+ if (strategy.Mode == SamplingMode.Debounce || strategy.Mode == SamplingMode.DebounceByKey)
+ return false;
+
+ // For Throttle mode, record the first event immediately
+ return true;
+ }
+
+ ///
+ /// Generates the sampling key based on the sampling mode.
+ /// - Throttle/Debounce: Key by event type only
+ /// - DebounceByKey: Key by event type + target (GlobalId)
+ ///
+ private static string GenerateSamplingKey(EditorEvent evt, SamplingMode mode)
+ {
+ // For DebounceByKey, include TargetId to distinguish different objects
+ if (mode == SamplingMode.DebounceByKey)
+ {
+ return $"{evt.Type}:{evt.TargetId}";
+ }
+
+ // For Throttle and Debounce, key by type only
+ return evt.Type;
+ }
+
+ ///
+ /// Removes expired samples from the cache.
+ ///
+ /// 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/Context/ContextStack.cs b/MCPForUnity/Editor/ActionTrace/Context/ContextStack.cs
new file mode 100644
index 000000000..7beabb204
--- /dev/null
+++ b/MCPForUnity/Editor/ActionTrace/Context/ContextStack.cs
@@ -0,0 +1,272 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using System.Threading;
+using MCPForUnity.Editor.Helpers;
+
+namespace MCPForUnity.Editor.ActionTrace.Context
+{
+ ///
+ /// Thread-local operation context stack for tracking operation source.
+ /// This is a "light marker" system - it doesn't control flow,
+ /// it only annotates operations with their source context.
+ ///
+ /// Design principle:
+ /// - Stack is lightweight (just references)
+ /// - No blocking operations
+ /// - Fast push/pop for using() pattern
+ /// - Thread-safe via ThreadStatic (each thread has its own stack)
+ ///
+ /// Threading model:
+ /// - Each thread maintains its own isolated context stack
+ /// - Unity Editor callbacks (delayCall, AssetPostprocessor) may run on different threads
+ /// - Context does not leak across thread boundaries
+ /// - Debug mode logs thread ID for diagnostics
+ ///
+ /// TODO-A Better clear strategy
+ public static class ContextStack
+ {
+ [ThreadStatic]
+ private static Stack _stack;
+
+ [ThreadStatic]
+ private static int _threadId; // For debug diagnostics
+
+ ///
+ /// Get the current operation context (if any).
+ /// Returns null if no context is active.
+ ///
+ public static OperationContext Current
+ {
+ get
+ {
+ var stack = GetStack();
+ return stack.Count > 0 ? stack.Peek() : null;
+ }
+ }
+
+ ///
+ /// Get the depth of the context stack.
+ ///
+ public static int Depth
+ {
+ get
+ {
+ return GetStack().Count;
+ }
+ }
+
+ ///
+ /// Get the thread-local stack, initializing if necessary.
+ ///
+ private static Stack GetStack()
+ {
+ if (_stack == null)
+ {
+ _stack = new Stack();
+ _threadId = Thread.CurrentThread.ManagedThreadId;
+
+#if DEBUG
+ McpLog.Info(
+ $"[ContextStack] Initialized new stack for thread {_threadId}");
+#endif
+ }
+ return _stack;
+ }
+
+ ///
+ /// Push a context onto the stack.
+ /// Returns a disposable that will pop the context when disposed.
+ ///
+ public static IDisposable Push(OperationContext context)
+ {
+ if (context == null)
+ throw new ArgumentNullException(nameof(context));
+
+ var stack = GetStack();
+ stack.Push(context);
+
+#if DEBUG
+ McpLog.Info(
+ $"[ContextStack] Push context {context.ContextId} on thread {_threadId}, depth: {stack.Count}");
+#endif
+
+ return new ContextDisposable(context);
+ }
+
+ ///
+ /// Pop the top context from the stack.
+ /// Validates that the popped context matches the expected one.
+ ///
+ public static bool Pop(OperationContext expectedContext)
+ {
+ var stack = GetStack();
+ if (stack.Count == 0)
+ {
+#if DEBUG
+ McpLog.Warn(
+ $"[ContextStack] Pop on empty stack (thread {_threadId}, expected {expectedContext?.ContextId})");
+#endif
+ return false;
+ }
+
+ var top = stack.Peek();
+ if (top.Equals(expectedContext))
+ {
+ stack.Pop();
+
+#if DEBUG
+ McpLog.Info(
+ $"[ContextStack] Pop context {expectedContext.ContextId} on thread {_threadId}, remaining depth: {stack.Count}");
+#endif
+
+ return true;
+ }
+
+ // Stack mismatch - this indicates a programming error
+ // Improvement: Only remove mismatched context, preserve valid ones
+ var currentThreadId = Thread.CurrentThread.ManagedThreadId;
+ var stackSnapshot = string.Join(", ", stack.Select(c => SafeGetShortId(c.ContextId)));
+
+ // Try to find and remove the mismatched context
+ var tempStack = new Stack();
+ bool found = false;
+
+ while (stack.Count > 0)
+ {
+ var item = stack.Pop();
+ if (item.Equals(expectedContext))
+ {
+ found = true;
+ break;
+ }
+ tempStack.Push(item);
+ }
+
+ // Restore valid contexts
+ while (tempStack.Count > 0)
+ {
+ stack.Push(tempStack.Pop());
+ }
+
+ if (!found)
+ {
+ McpLog.Warn(
+ $"[ContextStack] Expected context {expectedContext.ContextId} not found on thread {currentThreadId}\n" +
+ $" Stack snapshot: [{stackSnapshot}]\n" +
+ $" No changes made to stack.");
+ }
+
+ return found;
+ }
+
+ ///
+ /// Mark the current operation as an AI operation.
+ /// Returns a disposable for automatic cleanup.
+ ///
+ /// Usage:
+ /// using (ContextStack.MarkAsAiOperation("claude-opus"))
+ /// {
+ /// // All events recorded here are tagged as AI
+ /// }
+ ///
+ public static IDisposable MarkAsAiOperation(string agentId, string sessionId = null)
+ {
+ var context = OperationContextFactory.CreateAiContext(agentId, sessionId);
+ return Push(context);
+ }
+
+ ///
+ /// Mark the current operation as a human operation.
+ /// Returns a disposable for automatic cleanup.
+ ///
+ public static IDisposable MarkAsHumanOperation(string sessionId = null)
+ {
+ var context = OperationContextFactory.CreateHumanContext(sessionId);
+ return Push(context);
+ }
+
+ ///
+ /// Mark the current operation as a system operation.
+ /// Returns a disposable for automatic cleanup.
+ ///
+ public static IDisposable MarkAsSystemOperation(string sessionId = null)
+ {
+ var context = OperationContextFactory.CreateSystemContext(sessionId);
+ return Push(context);
+ }
+
+ ///
+ /// Check if the current context is from an AI source.
+ ///
+ public static bool IsAiOperation
+ {
+ get
+ {
+ var current = Current;
+ return current != null && current.Source == OperationSource.AI;
+ }
+ }
+
+ ///
+ /// Get the current agent ID (if AI operation).
+ ///
+ public static string CurrentAgentId
+ {
+ get
+ {
+ var current = Current;
+ return current?.Source == OperationSource.AI ? current.AgentId : null;
+ }
+ }
+
+ ///
+ /// Clear the entire stack (for error recovery).
+ /// Thread-safe: only clears the current thread's stack.
+ ///
+ public static void Clear()
+ {
+ var stack = GetStack();
+ stack.Clear();
+
+#if DEBUG
+ McpLog.Info(
+ $"[ContextStack] Cleared stack on thread {Thread.CurrentThread.ManagedThreadId}");
+#endif
+ }
+
+ ///
+ /// Safely extracts a short ID from a Guid, preventing null/empty exceptions.
+ ///
+ private static string SafeGetShortId(Guid guid)
+ {
+ var str = guid.ToString();
+ return str.Length >= 8 ? str.Substring(0, 8) : str;
+ }
+
+ ///
+ /// Disposable that pops the context when disposed.
+ /// Validates the context matches to prevent stack corruption.
+ ///
+ private sealed class ContextDisposable : IDisposable
+ {
+ private readonly OperationContext _context;
+ private bool _disposed;
+
+ public ContextDisposable(OperationContext context)
+ {
+ _context = context ?? throw new ArgumentNullException(nameof(context));
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+
+ Pop(_context);
+ _disposed = true;
+ }
+ }
+ }
+}
diff --git a/MCPForUnity/Editor/ActionTrace/Context/ContextTimeline.cs b/MCPForUnity/Editor/ActionTrace/Context/ContextTimeline.cs
new file mode 100644
index 000000000..bb885ce61
--- /dev/null
+++ b/MCPForUnity/Editor/ActionTrace/Context/ContextTimeline.cs
@@ -0,0 +1,78 @@
+using UnityEditor;
+using MCPForUnity.Editor.ActionTrace.Core.Store;
+using System;
+using MCPForUnity.Editor.Helpers;
+using MCPForUnity.Editor.ActionTrace.Core.Models;
+
+namespace MCPForUnity.Editor.ActionTrace.Context
+{
+ ///
+ /// Automatically associates events with the current context.
+ /// Subscribes to EventStore.EventRecorded and creates mappings.
+ ///
+ /// This is the "glue" that connects the event layer to the context layer.
+ /// Events are immutable - context is attached via side-table mapping.
+ ///
+ /// Threading safety:
+ /// - EventStore.EventRecorded is raised via delayCall (next editor update)
+ /// - This callback runs on main thread, safe to call AddContextMapping
+ /// - AddContextMapping is thread-safe (uses _queryLock internally)
+ ///
+ [InitializeOnLoad]
+ public static class ContextTrace
+ {
+ static ContextTrace()
+ {
+ // Subscribe to event recording
+ // EventStore already uses delayCall, so this won't cause re-entrancy
+ EventStore.EventRecorded += OnEventRecorded;
+ }
+
+ ///
+ /// Called when an event is recorded.
+ /// Associates the event with the current context (if any).
+ ///
+ private static void OnEventRecorded(EditorEvent @event)
+ {
+ try
+ {
+ var currentContext = ContextStack.Current;
+ if (currentContext != null)
+ {
+ // Create the mapping
+ var mapping = new ContextMapping(
+ eventSequence: @event.Sequence,
+ contextId: currentContext.ContextId
+ );
+
+ // Store in EventStore's side-table
+ EventStore.AddContextMapping(mapping);
+ }
+ }
+ catch (System.Exception ex)
+ {
+ McpLog.Warn(
+ $"[ContextTrace] Failed to create context mapping: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Manually associate an event with a context.
+ /// Use this for batch operations or deferred association.
+ ///
+ public static void Associate(long eventSequence, Guid contextId)
+ {
+ var mapping = new ContextMapping(eventSequence, contextId);
+ EventStore.AddContextMapping(mapping);
+ }
+
+ ///
+ /// Remove all mappings for a specific context.
+ /// Useful for cleanup after a batch operation.
+ ///
+ public static void DisassociateContext(Guid contextId)
+ {
+ EventStore.RemoveContextMappings(contextId);
+ }
+ }
+}
diff --git a/MCPForUnity/Editor/ActionTrace/Context/OperationContext.cs b/MCPForUnity/Editor/ActionTrace/Context/OperationContext.cs
new file mode 100644
index 000000000..50be3ab14
--- /dev/null
+++ b/MCPForUnity/Editor/ActionTrace/Context/OperationContext.cs
@@ -0,0 +1,142 @@
+using System;
+
+namespace MCPForUnity.Editor.ActionTrace.Context
+{
+ ///
+ /// Operation source type.
+ /// Human: Manual editor operation
+ /// AI: AI-assisted operation (Claude, Cursor, etc.)
+ /// System: Automated system operation
+ ///
+ public enum OperationSource
+ {
+ Human,
+ AI,
+ System
+ }
+
+ ///
+ /// Immutable context metadata for an operation.
+ /// This is a "light marker" - minimal data that doesn't interfere with event storage.
+ /// Associated with events via Side-Table (ContextMapping), not embedded in EditorEvent.
+ ///
+ public sealed class OperationContext : IEquatable
+ {
+ ///
+ /// Unique identifier for this context instance.
+ ///
+ public Guid ContextId { get; }
+
+ ///
+ /// Source of the operation (Human, AI, or System).
+ ///
+ public OperationSource Source { get; }
+
+ ///
+ /// Agent identifier (e.g., "claude-opus", "cursor", "vscode-copilot").
+ /// Null for Human/System operations.
+ ///
+ public string AgentId { get; }
+
+ ///
+ /// Operation start time in UTC milliseconds since Unix epoch.
+ ///
+ public long StartTimeUnixMs { get; }
+
+ ///
+ /// Optional user/session identifier for correlation.
+ ///
+ public string SessionId { get; }
+
+ public OperationContext(
+ Guid contextId,
+ OperationSource source,
+ string agentId = null,
+ long startTimeUnixMs = 0,
+ string sessionId = null)
+ {
+ ContextId = contextId;
+ Source = source;
+ AgentId = agentId;
+ StartTimeUnixMs = startTimeUnixMs > 0
+ ? startTimeUnixMs
+ : DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+ SessionId = sessionId;
+ }
+
+ public bool Equals(OperationContext other)
+ {
+ if (ReferenceEquals(null, other)) return false;
+ if (ReferenceEquals(this, other)) return true;
+ return ContextId.Equals(other.ContextId);
+ }
+
+ public override bool Equals(object obj)
+ {
+ return Equals(obj as OperationContext);
+ }
+
+ public override int GetHashCode()
+ {
+ return ContextId.GetHashCode();
+ }
+
+ public static bool operator ==(OperationContext left, OperationContext right)
+ {
+ return Equals(left, right);
+ }
+
+ public static bool operator !=(OperationContext left, OperationContext right)
+ {
+ return !Equals(left, right);
+ }
+ }
+
+ ///
+ /// Factory for creating common context types.
+ ///
+ public static class OperationContextFactory
+ {
+ ///
+ /// Create a context for an AI operation.
+ ///
+ public static OperationContext CreateAiContext(string agentId, string sessionId = null)
+ {
+ return new OperationContext(
+ Guid.NewGuid(),
+ OperationSource.AI,
+ agentId,
+ DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ sessionId
+ );
+ }
+
+ ///
+ /// Create a context for a human operation.
+ ///
+ public static OperationContext CreateHumanContext(string sessionId = null)
+ {
+ return new OperationContext(
+ Guid.NewGuid(),
+ OperationSource.Human,
+ null,
+ DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ sessionId
+ );
+ }
+
+ ///
+ /// Create a context for a system operation.
+ ///
+ public static OperationContext CreateSystemContext(string sessionId = null)
+ {
+ return new OperationContext(
+ Guid.NewGuid(),
+ OperationSource.System,
+ null,
+ DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ sessionId
+ );
+ }
+ }
+}
diff --git a/MCPForUnity/Editor/ActionTrace/Context/ToolCallScope.cs b/MCPForUnity/Editor/ActionTrace/Context/ToolCallScope.cs
new file mode 100644
index 000000000..a2df51744
--- /dev/null
+++ b/MCPForUnity/Editor/ActionTrace/Context/ToolCallScope.cs
@@ -0,0 +1,589 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Text;
+using System.Threading;
+using MCPForUnity.Editor.ActionTrace.Core.Models;
+using MCPForUnity.Editor.ActionTrace.Core.Store;
+using UnityEditor;
+
+namespace MCPForUnity.Editor.ActionTrace.Context
+{
+ ///
+ /// Represents a single tool call invocation scope.
+ /// Tracks the lifetime, events, and metadata of a tool call.
+ ///
+ public sealed class ToolCallScope : IDisposable
+ {
+ private static readonly ThreadLocal> _scopeStack =
+ new(() => new Stack());
+
+ private readonly string _toolName;
+ private readonly string _toolId;
+ private readonly Dictionary _parameters;
+ private readonly List _capturedEvents;
+ private readonly long _startTimestampMs;
+ private readonly List _childScopes;
+ private readonly ToolCallScope _parentScope;
+ private readonly int _createdThreadId; // Track thread where scope was created
+ private readonly System.Threading.SynchronizationContext _syncContext; // Capture sync context
+
+ private long _endTimestampMs;
+ private bool _isCompleted;
+ private string _result;
+ private string _errorMessage;
+ private bool _isDisposed;
+
+ ///
+ /// Unique identifier for this tool call.
+ ///
+ public string CallId { get; }
+
+ ///
+ /// Name of the tool being called.
+ ///
+ public string ToolName => _toolName;
+
+ ///
+ /// Optional tool identifier (for distinguishing overloaded tools).
+ ///
+ public string ToolId => _toolId;
+
+ ///
+ /// Parameters passed to the tool.
+ ///
+ public IReadOnlyDictionary Parameters => _parameters;
+
+ ///
+ /// Events captured during this tool call.
+ ///
+ public IReadOnlyList CapturedEvents => _capturedEvents;
+
+ ///
+ /// Child tool calls made during this scope.
+ ///
+ public IReadOnlyList ChildScopes => _childScopes;
+
+ ///
+ /// Parent scope if this is a nested call.
+ ///
+ public ToolCallScope Parent => _parentScope;
+
+ ///
+ /// Duration of the tool call in milliseconds.
+ ///
+ public long DurationMs => _endTimestampMs > 0
+ ? _endTimestampMs - _startTimestampMs
+ : DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - _startTimestampMs;
+
+ ///
+ /// Whether the tool call completed successfully.
+ ///
+ public bool IsCompleted => _isCompleted;
+
+ ///
+ /// Result of the tool call (if successful).
+ ///
+ public string Result => _result;
+
+ ///
+ /// Error message (if the call failed).
+ ///
+ public string ErrorMessage => _errorMessage;
+
+ ///
+ /// Current active scope for this thread.
+ ///
+ public static ToolCallScope Current => _scopeStack.Value.Count > 0 ? _scopeStack.Value.Peek() : null;
+
+ ///
+ /// Create a new tool call scope.
+ ///
+ public ToolCallScope(string toolName, string toolId = null, Dictionary parameters = null)
+ {
+ _toolName = toolName ?? throw new ArgumentNullException(nameof(toolName));
+ _toolId = toolId ?? toolName;
+ _parameters = parameters ?? new Dictionary();
+ _capturedEvents = new List();
+ _childScopes = new List();
+ _startTimestampMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+ _parentScope = Current;
+ _createdThreadId = Thread.CurrentThread.ManagedThreadId;
+ _syncContext = System.Threading.SynchronizationContext.Current; // Capture current sync context
+
+ CallId = GenerateCallId();
+
+ // Push to stack (only if on the same thread as creation)
+ _scopeStack.Value.Push(this);
+
+ // Notify parent
+ _parentScope?._childScopes.Add(this);
+
+ // Record start event
+ RecordStartEvent();
+ }
+
+ ///
+ /// Complete the tool call with a result.
+ ///
+ public void Complete(string result = null)
+ {
+ if (_isCompleted) return;
+
+ _result = result;
+ _isCompleted = true;
+ _endTimestampMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+
+ RecordCompletionEvent();
+ }
+
+ ///
+ /// Complete the tool call with an error.
+ ///
+ public void Fail(string errorMessage)
+ {
+ if (_isCompleted) return;
+
+ _errorMessage = errorMessage;
+ _isCompleted = true;
+ _endTimestampMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+
+ RecordErrorEvent();
+ }
+
+ ///
+ /// Record an event that occurred during this tool call.
+ ///
+ public void RecordEvent(EditorEvent evt)
+ {
+ if (evt != null && !_isDisposed)
+ {
+ _capturedEvents.Add(evt);
+ }
+ }
+
+ ///
+ /// Get all events from this scope and all child scopes (flattened).
+ ///
+ public List GetAllEventsFlattened()
+ {
+ var allEvents = new List(_capturedEvents);
+
+ foreach (var child in _childScopes)
+ {
+ allEvents.AddRange(child.GetAllEventsFlattened());
+ }
+
+ return allEvents;
+ }
+
+ ///
+ /// Get a summary of this tool call.
+ ///
+ public string GetSummary()
+ {
+ var summary = new StringBuilder();
+
+ summary.Append(_toolName);
+
+ if (_parameters.Count > 0)
+ {
+ summary.Append("(");
+ int i = 0;
+ foreach (var kvp in _parameters)
+ {
+ if (i > 0) summary.Append(", ");
+ summary.Append(kvp.Key).Append("=").Append(FormatValue(kvp.Value));
+ i++;
+ if (i >= 3)
+ {
+ summary.Append("...");
+ break;
+ }
+ }
+ summary.Append(")");
+ }
+
+ summary.Append($" [{DurationMs}ms]");
+
+ if (_errorMessage != null)
+ {
+ summary.Append($" ERROR: {_errorMessage}");
+ }
+ else if (_isCompleted)
+ {
+ summary.Append(" ✓");
+ }
+
+ if (_capturedEvents.Count > 0)
+ {
+ summary.Append($" ({_capturedEvents.Count} events)");
+ }
+
+ if (_childScopes.Count > 0)
+ {
+ summary.Append($" +{_childScopes.Count} nested calls");
+ }
+
+ return summary.ToString();
+ }
+
+ ///
+ /// Get detailed information about this tool call.
+ ///
+ public string GetDetails()
+ {
+ var details = new StringBuilder();
+
+ details.AppendLine($"=== Tool Call: {_toolName} ===");
+ details.AppendLine($"Call ID: {CallId}");
+ details.AppendLine($"Duration: {DurationMs}ms");
+ details.AppendLine($"Status: {_errorMessage ?? (_isCompleted ? "Completed" : "Running")}");
+
+ if (_parameters.Count > 0)
+ {
+ details.AppendLine("Parameters:");
+ foreach (var kvp in _parameters)
+ {
+ details.AppendLine($" {kvp.Key}: {FormatValue(kvp.Value)}");
+ }
+ }
+
+ if (_capturedEvents.Count > 0)
+ {
+ details.AppendLine($"Captured Events ({_capturedEvents.Count}):");
+ foreach (var evt in _capturedEvents)
+ {
+ details.AppendLine($" - [{evt.Type}] {evt.GetSummary()}");
+ }
+ }
+
+ if (_childScopes.Count > 0)
+ {
+ details.AppendLine($"Nested Calls ({_childScopes.Count}):");
+ foreach (var child in _childScopes)
+ {
+ details.AppendLine($" - {child.GetSummary()}");
+ }
+ }
+
+ if (_result != null)
+ {
+ details.AppendLine($"Result: {_result}");
+ }
+
+ return details.ToString();
+ }
+
+ public void Dispose()
+ {
+ if (_isDisposed) return;
+
+ // Auto-complete if not explicitly completed
+ if (!_isCompleted)
+ {
+ Complete();
+ }
+
+ // Pop from stack, marshaling back to original thread if needed
+ int currentThreadId = Thread.CurrentThread.ManagedThreadId;
+ var currentSyncContext = System.Threading.SynchronizationContext.Current;
+
+ // Check if we're on the correct thread (same thread as creation)
+ bool isCorrectThread = currentThreadId == _createdThreadId;
+ // Also check if sync contexts match (if available)
+ if (isCorrectThread && _syncContext != null && currentSyncContext != null)
+ {
+ isCorrectThread = currentSyncContext == _syncContext;
+ }
+
+ if (isCorrectThread)
+ {
+ // Same thread: safe to pop from stack directly
+ PopFromStack();
+ }
+ else
+ {
+ // Different thread: marshal cleanup back to original thread
+ if (_syncContext != null)
+ {
+ // Use captured SynchronizationContext to marshal back
+ _syncContext.Post(_ => PopFromStack(), null);
+ }
+ else
+ {
+ // Fallback: use delayCall if no sync context was captured
+ EditorApplication.delayCall += () => PopFromStack();
+ }
+ }
+
+ _isDisposed = true;
+ }
+
+ ///
+ /// Pops this scope from the stack. Must be called on the thread where the scope was created.
+ ///
+ private void PopFromStack()
+ {
+ if (_scopeStack.Value.Count > 0 && _scopeStack.Value.Peek() == this)
+ {
+ _scopeStack.Value.Pop();
+ }
+ }
+
+ private string GenerateCallId()
+ {
+ // Compact ID: tool name + timestamp + random suffix
+ long timestamp = _startTimestampMs % 1000000; // Last 6 digits of timestamp
+ int random = UnityEngine.Random.Range(1000, 9999);
+ return $"{_toolId}_{timestamp}_{random}";
+ }
+
+ private void RecordStartEvent()
+ {
+ var payload = new Dictionary
+ {
+ { "tool_name", _toolName },
+ { "call_id", CallId },
+ { "parent_call_id", _parentScope?.CallId ?? "" },
+ { "parameter_count", _parameters.Count }
+ };
+
+ foreach (var kvp in _parameters)
+ {
+ // Add parameters (truncated if too long)
+ string valueStr = FormatValue(kvp.Value);
+ if (valueStr != null && valueStr.Length > 100)
+ {
+ valueStr = valueStr.Substring(0, 97) + "...";
+ }
+ payload[$"param_{kvp.Key}"] = valueStr;
+ }
+
+ // Emit through EventStore
+ var evt = new EditorEvent(
+ sequence: 0, // Will be assigned by EventStore
+ timestampUnixMs: _startTimestampMs,
+ type: "ToolCallStarted",
+ targetId: CallId,
+ payload: payload
+ );
+ EventStore.Record(evt);
+ }
+
+ private void RecordCompletionEvent()
+ {
+ var payload = new Dictionary
+ {
+ { "tool_name", _toolName },
+ { "call_id", CallId },
+ { "duration_ms", DurationMs },
+ { "events_captured", _capturedEvents.Count },
+ { "nested_calls", _childScopes.Count }
+ };
+
+ if (_result != null && _result.Length <= 200)
+ {
+ payload["result"] = _result;
+ }
+
+ var completedEvt = new EditorEvent(
+ sequence: 0,
+ timestampUnixMs: _endTimestampMs,
+ type: "ToolCallCompleted",
+ targetId: CallId,
+ payload: payload
+ );
+ EventStore.Record(completedEvt);
+ }
+
+ private void RecordErrorEvent()
+ {
+ var payload = new Dictionary
+ {
+ { "tool_name", _toolName },
+ { "call_id", CallId },
+ { "duration_ms", DurationMs },
+ { "error", _errorMessage ?? "Unknown error" },
+ { "events_captured", _capturedEvents.Count }
+ };
+
+ var errorEvt = new EditorEvent(
+ sequence: 0,
+ timestampUnixMs: _endTimestampMs,
+ type: "ToolCallFailed",
+ targetId: CallId,
+ payload: payload
+ );
+ EventStore.Record(errorEvt);
+ }
+
+ private static string FormatValue(object value)
+ {
+ if (value == null) return "null";
+ if (value is string str) return $"\"{str}\"";
+ if (value is bool b) return b.ToString().ToLower();
+ return value.ToString();
+ }
+
+ // ========== Static Helper Methods ==========
+
+ ///
+ /// Create a new scope with automatic disposal.
+ /// Usage: using (ToolCallScope.Begin("manage_gameobject", params)) { ... }
+ ///
+ public static ToolCallScope Begin(string toolName, string toolId = null, Dictionary parameters = null)
+ {
+ return new ToolCallScope(toolName, toolId, parameters);
+ }
+
+ ///
+ /// Get the current scope's call ID (returns empty if no active scope).
+ ///
+ public static string GetCurrentCallId()
+ {
+ return Current?.CallId ?? "";
+ }
+
+ ///
+ /// Record an event in the current scope (if any).
+ ///
+ public static void RecordEventInCurrentScope(EditorEvent evt)
+ {
+ Current?.RecordEvent(evt);
+ }
+
+ ///
+ /// Get all active scopes in the current thread's hierarchy.
+ ///
+ public static List GetActiveHierarchy()
+ {
+ var hierarchy = new List();
+ var stack = _scopeStack.Value;
+
+ foreach (var scope in stack)
+ {
+ hierarchy.Add(scope);
+ }
+
+ hierarchy.Reverse(); // Root first
+ return hierarchy;
+ }
+
+ ///
+ /// Get the root scope (outermost call) in the current hierarchy.
+ ///
+ public static ToolCallScope GetRootScope()
+ {
+ var stack = _scopeStack.Value;
+ if (stack.Count == 0) return null;
+
+ // The bottom of the stack is the root
+ return stack.ToArray()[^1];
+ }
+ }
+
+ ///
+ /// Helper methods for common tool call instrumentation patterns.
+ ///
+ public static class ToolCall
+ {
+ ///
+ /// Execute a function within a tool call scope, automatically recording duration and result.
+ ///
+ public static T Execute(string toolName, Func func, string toolId = null, Dictionary parameters = null)
+ {
+ using var scope = new ToolCallScope(toolName, toolId, parameters);
+
+ try
+ {
+ T result = func();
+ scope.Complete(result?.ToString() ?? "");
+ return result;
+ }
+ catch (Exception ex)
+ {
+ scope.Fail(ex.Message);
+ throw;
+ }
+ }
+
+ ///
+ /// Execute an async function within a tool call scope.
+ /// The scope is disposed when the async operation completes or faults.
+ ///
+ public static System.Threading.Tasks.Task ExecuteAsync(
+ string toolName,
+ Func> func,
+ string toolId = null,
+ Dictionary parameters = null)
+ {
+ var scope = new ToolCallScope(toolName, toolId, parameters);
+
+ var task = func();
+
+ return task.ContinueWith(t =>
+ {
+ try
+ {
+ if (t.IsFaulted)
+ {
+ scope.Fail(t.Exception?.Message ?? "Async faulted");
+ throw t.Exception ?? new Exception("Async task faulted");
+ }
+ else
+ {
+ scope.Complete(t.Result?.ToString() ?? "");
+ return t.Result;
+ }
+ }
+ finally
+ {
+ // Always dispose to prevent stack leak
+ scope.Dispose();
+ }
+ }, System.Threading.Tasks.TaskScheduler.Default);
+ }
+
+ ///
+ /// Execute an action within a tool call scope.
+ ///
+ public static void Execute(string toolName, Action action, string toolId = null, Dictionary parameters = null)
+ {
+ using var scope = new ToolCallScope(toolName, toolId, parameters);
+
+ try
+ {
+ action();
+ scope.Complete();
+ }
+ catch (Exception ex)
+ {
+ scope.Fail(ex.Message);
+ throw;
+ }
+ }
+
+ ///
+ /// Measure execution time of a function without creating a scope.
+ ///
+ public static (T result, long ms) Measure(Func func)
+ {
+ var sw = Stopwatch.StartNew();
+ T result = func();
+ sw.Stop();
+ return (result, sw.ElapsedMilliseconds);
+ }
+
+ ///
+ /// Measure execution time of an action without creating a scope.
+ ///
+ public static long Measure(Action action)
+ {
+ var sw = Stopwatch.StartNew();
+ action();
+ sw.Stop();
+ return sw.ElapsedMilliseconds;
+ }
+ }
+}
diff --git a/MCPForUnity/Editor/ActionTrace/Core/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