diff --git a/src/OpenClaw.Shared/NotificationCategorizer.cs b/src/OpenClaw.Shared/NotificationCategorizer.cs
index 8559d69..20d61a2 100644
--- a/src/OpenClaw.Shared/NotificationCategorizer.cs
+++ b/src/OpenClaw.Shared/NotificationCategorizer.cs
@@ -51,16 +51,23 @@ public class NotificationCategorizer
///
/// Classify a notification using the layered pipeline.
+ /// When is (the default),
+ /// structured metadata (Intent, Channel) is checked first.
+ /// When , structured metadata is skipped and classification starts
+ /// from user-defined rules, then keyword fallback.
///
- public (string title, string type) Classify(OpenClawNotification notification, IReadOnlyList? userRules = null)
+ public (string title, string type) Classify(OpenClawNotification notification, IReadOnlyList? userRules = null, bool preferStructuredCategories = true)
{
- // 1. Structured metadata: Intent
- if (!string.IsNullOrEmpty(notification.Intent) && IntentMap.TryGetValue(notification.Intent, out var intentResult))
- return intentResult;
+ if (preferStructuredCategories)
+ {
+ // 1. Structured metadata: Intent
+ if (!string.IsNullOrEmpty(notification.Intent) && IntentMap.TryGetValue(notification.Intent, out var intentResult))
+ return intentResult;
- // 2. Structured metadata: Channel
- if (!string.IsNullOrEmpty(notification.Channel) && ChannelMap.TryGetValue(notification.Channel, out var channelResult))
- return channelResult;
+ // 2. Structured metadata: Channel
+ if (!string.IsNullOrEmpty(notification.Channel) && ChannelMap.TryGetValue(notification.Channel, out var channelResult))
+ return channelResult;
+ }
// 3. User-defined rules (pattern match on title + message)
if (userRules is { Count: > 0 })
diff --git a/src/OpenClaw.Shared/OpenClawGatewayClient.cs b/src/OpenClaw.Shared/OpenClawGatewayClient.cs
index 0e21836..ccc08d9 100644
--- a/src/OpenClaw.Shared/OpenClawGatewayClient.cs
+++ b/src/OpenClaw.Shared/OpenClawGatewayClient.cs
@@ -22,6 +22,17 @@ public class OpenClawGatewayClient : WebSocketClientBase
private bool _usageCostUnsupported;
private bool _sessionPreviewUnsupported;
private bool _nodeListUnsupported;
+ private bool _preferStructuredCategories = true;
+
+ ///
+ /// Controls whether structured notification metadata (Intent, Channel) takes priority
+ /// over keyword-based classification. Mirrors the PreferStructuredCategories
+ /// setting. Call after construction and whenever settings change.
+ ///
+ public void SetPreferStructuredCategories(bool value)
+ {
+ _preferStructuredCategories = value;
+ }
private void ResetUnsupportedMethodFlags()
{
@@ -851,7 +862,7 @@ private void EmitChatNotification(string text)
Message = displayText,
IsChat = true
};
- var (title, type) = _categorizer.Classify(notification);
+ var (title, type) = _categorizer.Classify(notification, preferStructuredCategories: _preferStructuredCategories);
notification.Title = title;
notification.Type = type;
NotificationReceived?.Invoke(this, notification);
@@ -1556,7 +1567,7 @@ private void EmitNotification(string text)
{
Message = text.Length > 200 ? text[..200] + "…" : text
};
- var (title, type) = _categorizer.Classify(notification);
+ var (title, type) = _categorizer.Classify(notification, preferStructuredCategories: _preferStructuredCategories);
notification.Title = title;
notification.Type = type;
NotificationReceived?.Invoke(this, notification);
diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs
index de0780f..e9cde48 100644
--- a/src/OpenClaw.Tray.WinUI/App.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs
@@ -1085,6 +1085,7 @@ private void InitializeGatewayClient()
UnsubscribeGatewayEvents();
_gatewayClient = new OpenClawGatewayClient(_settings.GatewayUrl, _settings.Token, new AppLogger());
+ _gatewayClient.SetPreferStructuredCategories(_settings.PreferStructuredCategories);
_gatewayClient.StatusChanged += OnConnectionStatusChanged;
_gatewayClient.ActivityChanged += OnActivityChanged;
_gatewayClient.NotificationReceived += OnNotificationReceived;
diff --git a/tests/OpenClaw.Shared.Tests/NotificationCategorizerTests.cs b/tests/OpenClaw.Shared.Tests/NotificationCategorizerTests.cs
index bb2354c..723ba6c 100644
--- a/tests/OpenClaw.Shared.Tests/NotificationCategorizerTests.cs
+++ b/tests/OpenClaw.Shared.Tests/NotificationCategorizerTests.cs
@@ -272,7 +272,82 @@ public void PipelineOrder_Intent_Channel_UserRules_Keywords()
Assert.Equal("health", _categorizer.Classify(notification).type);
}
- // --- ClassifyByKeywords static method ---
+ // --- PreferStructuredCategories = false ---
+
+ [Fact]
+ public void PreferStructuredCategories_False_SkipsIntent()
+ {
+ // Intent says "build" but with preferStructuredCategories=false it should be ignored;
+ // message keyword "email" drives the result.
+ var notification = new OpenClawNotification
+ {
+ Message = "New email notification",
+ Intent = "build"
+ };
+ var (_, type) = _categorizer.Classify(notification, preferStructuredCategories: false);
+ Assert.Equal("email", type);
+ }
+
+ [Fact]
+ public void PreferStructuredCategories_False_SkipsChannel()
+ {
+ // Channel says "calendar" but with preferStructuredCategories=false it should be ignored;
+ // message keyword "email" drives the result.
+ var notification = new OpenClawNotification
+ {
+ Message = "Check your email",
+ Channel = "calendar"
+ };
+ var (_, type) = _categorizer.Classify(notification, preferStructuredCategories: false);
+ Assert.Equal("email", type);
+ }
+
+ [Fact]
+ public void PreferStructuredCategories_False_UserRulesStillApply()
+ {
+ var rules = new List
+ {
+ new() { Pattern = "invoice", Category = "email", Enabled = true }
+ };
+ // Intent would win when preferStructuredCategories=true, but is skipped here;
+ // user rule matches the message.
+ var notification = new OpenClawNotification
+ {
+ Message = "New invoice received",
+ Intent = "urgent"
+ };
+ var (_, type) = _categorizer.Classify(notification, rules, preferStructuredCategories: false);
+ Assert.Equal("email", type);
+ }
+
+ [Fact]
+ public void PreferStructuredCategories_False_FallsBackToKeywords()
+ {
+ // No user rules, no keywords for "hello world" → "info"
+ var notification = new OpenClawNotification
+ {
+ Message = "Hello world",
+ Intent = "build",
+ Channel = "email"
+ };
+ var (_, type) = _categorizer.Classify(notification, preferStructuredCategories: false);
+ Assert.Equal("info", type);
+ }
+
+ [Fact]
+ public void PreferStructuredCategories_True_Default_BehaviourUnchanged()
+ {
+ // Ensure the default (true) still gives structured metadata priority.
+ var notification = new OpenClawNotification
+ {
+ Message = "New email notification",
+ Intent = "build"
+ };
+ Assert.Equal("build", _categorizer.Classify(notification).type);
+ Assert.Equal("build", _categorizer.Classify(notification, preferStructuredCategories: true).type);
+ }
+
+
[Fact]
public void ClassifyByKeywords_DefaultsToInfo()