package skills import ( "path" "strings" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" ) // 本文件实现 design/09 §6.2 里 `triggers` 与 `paths` 两枚字段的**判定语义**: // 它们一起决定技能是否**自动激活**(对齐 Agent Skills 的 `paths` 限定自动激活)。 // // 判定本身是纯函数,真正的「激活 + 注入正文」落在 loop 片(design/09 §6.3)。 // MatchPaths 报告 filePaths 里是否有文件命中 patterns 里的任一 glob。 // patterns 为空 ⇒ false(没声明就不自动激活,而不是「任意文件都命中」)。 func MatchPaths(patterns, filePaths []string) bool { for _, pattern := range patterns { for _, file := range filePaths { if MatchGlob(pattern, file) { return true } } } return false } // MatchGlob 按 glob 语义匹配一个路径。 // // 语义(刻意写明,避免与别处的 glob 库猜来猜去): // // - 路径分隔符统一成 `/`,Windows 的 `\` 会被归一; // - `*` / `?` / `[...]` 用 path.Match 的单段语义,**不跨目录分隔符**; // - `**` 匹配**零到多段**(如 `src/**/*.ts` 命中 `src/a/b.ts` 与 `src/b.ts`); // - 模式里不含 `/` 时,额外按**文件名**匹配一次(写 `*.go` 的人想的是「任何 // .go 文件」,而不是「根目录下的 .go 文件」)。 // // 语法非法的模式一律返回 false(加载期 Validate 已经拒过它们)。 func MatchGlob(pattern, file string) bool { pattern = normalizePath(pattern) file = normalizePath(file) if pattern == "" || file == "" { return false } if globSegments(strings.Split(pattern, "/"), strings.Split(file, "/")) { return true } if !strings.Contains(pattern, "/") { base := file if idx := strings.LastIndex(file, "/"); idx >= 0 { base = file[idx+1:] } // 模式不含 /:额外按文件名匹配一次(写 *.go 的人想的是「任何 .go 文件」)。 // 这里直接走段匹配 —— 再调 MatchGlob 会拿着同样的无斜杠模式无限递归。 return globSegments(strings.Split(pattern, "/"), []string{base}) } return false } // globSegments 递归匹配已切段的两条路径,`**` 可吃掉零到多段。 func globSegments(pattern, name []string) bool { if len(pattern) == 0 { return len(name) == 0 } if pattern[0] == "**" { for i := 0; i <= len(name); i++ { if globSegments(pattern[1:], name[i:]) { return true } } return false } if len(name) == 0 { return false } ok, err := path.Match(pattern[0], name[0]) if err != nil || !ok { return false } return globSegments(pattern[1:], name[1:]) } // MatchTriggers 报告文本里是否出现任一触发词(大小写不敏感的子串匹配)。 // triggers 为空 ⇒ false。 func MatchTriggers(triggers []string, text string) bool { if text == "" { return false } lower := strings.ToLower(text) for _, trigger := range triggers { trimmed := strings.ToLower(strings.TrimSpace(trigger)) if trimmed != "" && strings.Contains(lower, trimmed) { return true } } return false } // AutoActivates 报告技能是否因当前回合的文本/工作文件而**自动激活**: // 触发词命中**或**工作文件命中 paths。两者都没声明 ⇒ 不自动激活(只能显式调用)。 func AutoActivates(s sdk.Skill, text string, workFiles []string) bool { if s == nil { return false } spec := sdk.SkillView(s) triggers, paths := spec.Triggers(), spec.Paths() if len(triggers) == 0 && len(paths) == 0 { return false } return MatchTriggers(triggers, text) || MatchPaths(paths, workFiles) } // normalizePath 统一分隔符并去掉首尾空白;不做其它解释(不做绝对路径归一, // 判定只关心「模式是否覆盖这个文件」)。 func normalizePath(p string) string { return strings.TrimSpace(strings.ReplaceAll(p, "\\", "/")) }