docs/StrategyEngine/03-MatchingSystem.md
The matching system decides which Strategies should be recommended for the user's current context.
It must be:
The output should eventually be IReadOnlyList<StrategyCandidate>. Existing code may temporarily keep IReadOnlyList<Strategy> as a compatibility projection.
1. Load enabled strategy sources
2. Parse into versioned StrategyDefinitionVx documents
3. Normalize documents into runtime Strategy objects
4. Validate and collect diagnostics during normalization
5. Analyze condition dependencies
6. Build base StrategyContext
7. Collect required ExtraContext subtrees
8. Evaluate conditions with bool? semantics
9. Filter strategies where result == true
10. Sort by priority
11. Emit diagnostics and slow-match notifications
Providers return raw strategy sources:
public interface IStrategyDocumentProvider
{
string ProviderId { get; }
IAsyncEnumerable<StrategyDocument> LoadAsync(CancellationToken cancellationToken);
}
Recommended provider responsibilities:
| Provider | Responsibility |
|---|---|
| Builtin provider | Return compiled Strategy definitions or embedded markdown resources. |
| User directory provider | Load user .strategy.md files. |
| Workspace provider | Load workspace-local .strategy.md files when workspace support exists. |
| Plugin/app provider | Return plugin contributed definitions when the plugin model supports this. |
Providers should not evaluate conditions. They only load definitions.
from ResolutionIf a Strategy file contains from, the normalizer resolves that source as part of compiling StrategyDefinitionVx into runtime Strategy.
This is intentionally include-like. It may look like copy-paste replacement in the final Strategy, but the implementation must preserve the include reference for diagnostics, navigation, cache invalidation, and future editor UI.
Normalization algorithm:
IStrategyDefinitionNormalizer by schema.from exists, resolve from.source using registered source resolvers.Strategy.Strategy.Source.Strategy.Includes.The normalizer should be hand-written. Do not hide this path behind AutoMapper-style runtime mapping. A source generator may assist mechanical field copying, but source resolution, defaulting, validation, condition compilation, icon/duration parsing, namespace assignment, and diagnostics belong in the normalizer.
Source resolver interface:
public interface IStrategySourceResolver
{
bool CanResolve(StrategyFromReference reference, StrategySource currentSource);
Task<StrategyDocument> ResolveAsync(
StrategyFromReference reference,
StrategySource currentSource,
CancellationToken cancellationToken);
}
v1 required resolvers:
skill://id) once import UI exists.URL resolver is interface-ready but may be disabled in v1.
Cycle handling:
from depth greater than 1 is invalid in v1..strategy.md with its own from, reject it in v1.Validation must happen before matching.
Required validation errors:
id after provider namespace assignment.builtin.*.when structure.options duration.tools value type.Validation diagnostics should include file path, line/column when available, and a user-readable explanation.
Invalid user Strategies must be skipped. Invalid builtin Strategies should be treated as developer errors and logged prominently.
The engine infers required context by walking the when tree.
Example:
when:
all:
- extra.file_manager.selection.items:
count: { min: 1 }
- clipboard.text:
contains: "TODO"
- visual.exists:
query: "//ListViewItem[@selected=true]"
Dependency result:
extra.file_manager
clipboard.text
visual
No separate requires section is needed. If a condition references extra.browser.active_tab.url, the engine should infer that an ExtraContext provider capable of extra.browser may be needed.
Recommended representation:
public sealed record StrategyContextRequirements
{
public bool NeedsClipboard { get; init; }
public bool NeedsVisualTree { get; init; }
public IReadOnlySet<string> ExtraRoots { get; init; } = new HashSet<string>();
public IReadOnlySet<string> AssistantPaths { get; init; } = new HashSet<string>();
}
Extra roots should be coarse enough to avoid running too many providers:
extra.file_manager
extra.browser
extra.workspace
Base context is collected from data already available to Everywhere or cheap platform APIs:
The existing StrategyContext.FromAttachments(...) can be retained but should be expanded or wrapped so matching is not attachment-only.
Recommended target:
public interface IStrategyContextFactory
{
Task<StrategyContext> CreateAsync(
StrategyContextRequirements requirements,
IReadOnlyList<ChatAttachment> attachments,
CancellationToken cancellationToken);
}
ExtraContext providers fill the extra subtree. Authors write cross-platform paths like:
extra.file_manager.selection.items
The engine chooses a provider based on platform and current active context.
Recommended interface:
public interface IExtraContextProvider
{
string Id { get; } // windows.explorer, macos.finder
string PublicRoot { get; } // extra.file_manager
IDynamicResourceKey PermissionDescriptionKey { get; }
bool CanCollect(StrategyContext baseContext, ExtraContextRequest request);
Task<ExtraContextNode?> CollectAsync(
StrategyContext baseContext,
ExtraContextRequest request,
CancellationToken cancellationToken);
}
Rules:
CanCollect.null for its subtree and a diagnostic entry.null.Public root:
extra.file_manager
Windows implementation:
kind: virtual and path: null.macOS implementation:
null and a permission diagnostic.The matching DSL should not mention windows.explorer or macos.finder unless a future platform-specific extra root is intentionally introduced.
All conditions return bool?.
show strategy if and only if root condition == true
false and null both hide the Strategy from the recommendation list.
all:
if any child is false -> false
else if all children are true -> true
else -> null
any:
if any child is true -> true
else if all children are false -> false
else -> null
none:
let r = any(children)
if r == true -> false
if r == false -> true
if r == null -> null
This prevents missing data from accidentally satisfying negative checks.
A field condition maps a path to an operator object.
attachments.selection.text:
length:
min: 1
Evaluation steps:
StrategyContext.null.false and a diagnostic.Supported operators:
equals: "chrome"
in: ["chrome", "msedge"]
contains: "invoice"
startsWith: "https://"
endsWith: ".pdf"
regex: "\\bTODO\\b"
glob: "*.pdf"
caseSensitive: true
Rules:
caseSensitive: true applies to the condition object.options.regexTimeout.null and emits a diagnostic.false with diagnostic.For numbers:
size:
min: 1024
max: 10485760
For string length:
length:
min: 1
max: 5000
For arrays:
count:
min: 1
max: 10
If value type does not support the operator, return false.
Arrays support:
count:
min: 1
any:
extension:
in: [".pdf", ".docx"]
all:
kind:
equals: "file"
none:
extension:
in: [".exe", ".bat"]
Array item object matching rules:
any/all/none is interpreted as a relative path from the item.any is true if at least one item matches.all is true only if all items match and the array is not empty.none follows the same three-valued semantics as condition-level none.null.v1 supports exactly three visual conditions:
visual.exists:
query: "//TopLevel//ListViewItem[@selected=true]"
visual.count:
query: "//ListViewItem[@selected=true]"
min: 1
max: 5
visual.match:
query: "//TopLevel/@name"
contains: "Visual Studio"
Evaluation:
null.null.visual.exists returns true if at least one element matches, otherwise false.visual.count returns true if result count is within range, otherwise false.visual.match selects attribute values and applies normal operators.The visual query DSL is XPath-like, not XPath.
Supported:
| Syntax | Meaning |
|---|---|
//Button | Any descendant with visual type Button. |
/TopLevel/Panel/Button | Strict parent-child path. |
. | Current primary visual attachment. |
* | Any visual type. |
@name | Read element name. |
@text | Read element text. |
@process | Read process name. |
@selected | Read selected state. |
@focused | Read focused state. |
@disabled | Read disabled state. |
@readonly | Read read-only state. |
@offscreen | Read offscreen state. |
@password | Read password state. |
@bounds.width | Read bounds width. |
[@selected=true] | Boolean filter. |
[@name='Save'] | Equality filter. |
contains(@name,'Save') | Contains filter. |
| `matches(@text,'error | warning')` |
Not supported in v1:
//Button[3].@text can be expensive. It must only be read when explicitly referenced, and must respect text length limits and timeouts.
Matched Strategies are sorted by:
priority descending.No score-based matching in v1. Matching is boolean.
Timeouts come from options, with global defaults.
Recommended defaults:
options:
matchingTimeout: 300ms
conditionTimeout: 80ms
regexTimeout: 50ms
visualQueryTimeout: 120ms
extraTimeout: 200ms
Rules:
matchingTimeout is the total budget for one Strategy evaluation.conditionTimeout is the default budget for one condition node.regexTimeout applies per regex operation.visualQueryTimeout applies per visual query.extraTimeout applies per ExtraContext provider call.null, not false.If one or more Strategies are skipped due to timeout or slow matching, the normal UI should show a Toast.
Suggested user text:
Some strategies took too long to check and were skipped.
Requirements:
Diagnostics should be structured:
public sealed record StrategyDiagnostic
{
public required StrategyDiagnosticSeverity Severity { get; init; }
public required string Code { get; init; }
public IDynamicResourceKey? MessageKey { get; init; }
public string? Path { get; init; }
public string? ProviderId { get; init; }
public TimeSpan? Duration { get; init; }
public Exception? Exception { get; init; }
}
Common diagnostic codes:
strategy.invalid_yaml
strategy.invalid_from
strategy.unknown_preprocessor
condition.path_missing
condition.type_mismatch
condition.timeout
regex.invalid
regex.timeout
visual.query_invalid
visual.query_timeout
extra.provider_unavailable
extra.provider_timeout
extra.permission_unavailable
Diagnostics must not leak sensitive text content by default. Use path names and summaries, not full clipboard/file contents.