Because it's contains and not a word/phrase match, a trigger keyword fires whenever it appears inside another word, not just as its own token. Short or common keywords match constantly:
ui fires on "build", "guide", "require"
review fires on "preview the deployment"
next fires on almost any message
data fires on "update", "database", "metadata"
form fires on "perform", "information"
Why it matters: when a fragment matches, the skill's entire body is injected into the system prompt. On local/quantized models with a small context window, several unrelated skill bodies get pulled in on ordinary turns — wasting context and steering the model with irrelevant instructions. It's a silent, per-request context tax.
Expected: a trigger should match only as a whole word or phrase — bounded by non-alphanumeric characters or string edges. ui should match "build a ui" but not "build"; multi-word phrases (code review, app router) and non-alphanumeric edges (/plan, requirements.txt) should still match.
Proposed fix: replace the substring check with a boundary-aware match. Patch attached (word-boundary-triggers.patch) — adds a triggerMatches(::) helper and swaps the one call site; ~23 lines, no API change.
Compatibility: standalone-word triggers behave the same; only fragment matches stop firing. Anyone relying on fragment matches (unlikely, and undesirable) would see fewer spurious injections.
@@ -418,6 +418,28 @@ class SkillManager {
system prompt when a trigger matches.)
"""
+ /// Whole-word / phrase trigger match: `keyword` must appear in `text` bounded by
+ /// non-alphanumeric characters (or the string edges). This replaces a plain
+ /// `text.contains(keyword)`, which fired on substrings inside unrelated words —
+ /// e.g. "ui" matched "b**ui**ld", "review" matched "p**review**", "next" matched
+ /// almost anything — flooding a small model's context with the wrong skill bodies.
+ /// Multi-word phrases ("code review", "app router") and non-alphanumeric edges
+ /// ("/plan", "requirements.txt") still match correctly. `text` is already lowercased.
+ static func triggerMatches(_ text: String, _ keyword: String) -> Bool {
+ guard !keyword.isEmpty else { return false }
+ func isAlnum(_ c: Character) -> Bool { c.isLetter || c.isNumber }
+ var searchStart = text.startIndex
+ while let range = text.range(of: keyword, range: searchStart..<text.endIndex) {
+ let beforeOK = range.lowerBound == text.startIndex
+ || !isAlnum(text[text.index(before: range.lowerBound)])
+ let afterOK = range.upperBound == text.endIndex
+ || !isAlnum(text[range.upperBound])
+ if beforeOK && afterOK { return true }
+ searchStart = text.index(after: range.lowerBound)
+ }
+ return false
+ }
+
/// Returns skill index (always) + matching skill bodies (when triggered).
func matchingSkills(for userMessage: String) -> String {
reloadIfNeeded()
@@ -427,7 +449,7 @@ class SkillManager {
var result = "\nAvailable skills: " + skills.map { "\($0.name) (\($0.description))" }.joined(separator: ", ")
let matched = skills.filter { skill in
- skill.triggers.contains { lower.contains($0) }
+ skill.triggers.contains { Self.triggerMatches(lower, $0) }
}
for skill in matched {
result += "\n\n## Skill: \(skill.name)\n\(skill.body)"
Because it's contains and not a word/phrase match, a trigger keyword fires whenever it appears inside another word, not just as its own token. Short or common keywords match constantly:
ui fires on "build", "guide", "require"
review fires on "preview the deployment"
next fires on almost any message
data fires on "update", "database", "metadata"
form fires on "perform", "information"
Why it matters: when a fragment matches, the skill's entire body is injected into the system prompt. On local/quantized models with a small context window, several unrelated skill bodies get pulled in on ordinary turns — wasting context and steering the model with irrelevant instructions. It's a silent, per-request context tax.
Expected: a trigger should match only as a whole word or phrase — bounded by non-alphanumeric characters or string edges. ui should match "build a ui" but not "build"; multi-word phrases (code review, app router) and non-alphanumeric edges (/plan, requirements.txt) should still match.
Proposed fix: replace the substring check with a boundary-aware match. Patch attached (word-boundary-triggers.patch) — adds a triggerMatches(::) helper and swaps the one call site; ~23 lines, no API change.
Compatibility: standalone-word triggers behave the same; only fragment matches stop firing. Anyone relying on fragment matches (unlikely, and undesirable) would see fewer spurious injections.