weflow-cli 1.8.2 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/ARCHITECTURE.md +62 -26
  2. package/CHANGELOG.md +1290 -0
  3. package/OPERATIONS.md +144 -11
  4. package/README.en.md +20 -4
  5. package/README.md +30 -5
  6. package/bin/weflow-cli-electron.cjs +2 -0
  7. package/bin/weflow-cli.ts +996 -118
  8. package/dist/bin/weflow-cli.js +1038 -146
  9. package/dist/bin/weflow-cli.js.map +1 -1
  10. package/dist/mcp-server/http.d.ts +14 -0
  11. package/dist/mcp-server/http.d.ts.map +1 -0
  12. package/dist/mcp-server/http.js +84 -0
  13. package/dist/mcp-server/http.js.map +1 -0
  14. package/dist/mcp-server/httpConfig.d.ts +50 -0
  15. package/dist/mcp-server/httpConfig.d.ts.map +1 -0
  16. package/dist/mcp-server/httpConfig.js +104 -0
  17. package/dist/mcp-server/httpConfig.js.map +1 -0
  18. package/dist/mcp-server/index.d.ts +13 -1
  19. package/dist/mcp-server/index.d.ts.map +1 -1
  20. package/dist/mcp-server/index.js +58 -374
  21. package/dist/mcp-server/index.js.map +1 -1
  22. package/dist/src/core/dbPathService.d.ts +12 -0
  23. package/dist/src/core/dbPathService.d.ts.map +1 -1
  24. package/dist/src/core/dbPathService.js +20 -3
  25. package/dist/src/core/dbPathService.js.map +1 -1
  26. package/dist/src/panel/server.d.ts.map +1 -1
  27. package/dist/src/panel/server.js +61 -1
  28. package/dist/src/panel/server.js.map +1 -1
  29. package/dist/src/services/assistantEval.d.ts +69 -0
  30. package/dist/src/services/assistantEval.d.ts.map +1 -1
  31. package/dist/src/services/assistantEval.js +270 -8
  32. package/dist/src/services/assistantEval.js.map +1 -1
  33. package/dist/src/services/assistantScenes.d.ts +81 -0
  34. package/dist/src/services/assistantScenes.d.ts.map +1 -0
  35. package/dist/src/services/assistantScenes.js +322 -0
  36. package/dist/src/services/assistantScenes.js.map +1 -0
  37. package/dist/src/services/assistantService.d.ts +33 -4
  38. package/dist/src/services/assistantService.d.ts.map +1 -1
  39. package/dist/src/services/assistantService.js +102 -6
  40. package/dist/src/services/assistantService.js.map +1 -1
  41. package/dist/src/services/assistantSkills.d.ts +94 -0
  42. package/dist/src/services/assistantSkills.d.ts.map +1 -0
  43. package/dist/src/services/assistantSkills.js +330 -0
  44. package/dist/src/services/assistantSkills.js.map +1 -0
  45. package/dist/src/services/assistantTools.d.ts +34 -1
  46. package/dist/src/services/assistantTools.d.ts.map +1 -1
  47. package/dist/src/services/assistantTools.js +931 -88
  48. package/dist/src/services/assistantTools.js.map +1 -1
  49. package/dist/src/services/assistantTrace.d.ts +4 -0
  50. package/dist/src/services/assistantTrace.d.ts.map +1 -1
  51. package/dist/src/services/assistantTrace.js +9 -0
  52. package/dist/src/services/assistantTrace.js.map +1 -1
  53. package/dist/src/services/configService.d.ts +9 -0
  54. package/dist/src/services/configService.d.ts.map +1 -1
  55. package/dist/src/services/configService.js +5 -2
  56. package/dist/src/services/configService.js.map +1 -1
  57. package/dist/src/services/messageQuery.d.ts +18 -0
  58. package/dist/src/services/messageQuery.d.ts.map +1 -1
  59. package/dist/src/services/messageQuery.js +29 -0
  60. package/dist/src/services/messageQuery.js.map +1 -1
  61. package/dist/src/services/outboundSend.d.ts +53 -0
  62. package/dist/src/services/outboundSend.d.ts.map +1 -0
  63. package/dist/src/services/outboundSend.js +90 -0
  64. package/dist/src/services/outboundSend.js.map +1 -0
  65. package/dist/src/services/pythonBridge.d.ts.map +1 -1
  66. package/dist/src/services/pythonBridge.js +1 -0
  67. package/dist/src/services/pythonBridge.js.map +1 -1
  68. package/dist/src/services/wechatClient.d.ts +125 -0
  69. package/dist/src/services/wechatClient.d.ts.map +1 -0
  70. package/dist/src/services/wechatClient.js +344 -0
  71. package/dist/src/services/wechatClient.js.map +1 -0
  72. package/dist/src/services/wechatMedia.d.ts +44 -0
  73. package/dist/src/services/wechatMedia.d.ts.map +1 -0
  74. package/dist/src/services/wechatMedia.js +155 -0
  75. package/dist/src/services/wechatMedia.js.map +1 -0
  76. package/dist/src/services/wechatMessageService.d.ts +11 -0
  77. package/dist/src/services/wechatMessageService.d.ts.map +1 -1
  78. package/dist/src/services/wechatMessageService.js +55 -11
  79. package/dist/src/services/wechatMessageService.js.map +1 -1
  80. package/dist/src/types.d.ts +4 -0
  81. package/dist/src/types.d.ts.map +1 -1
  82. package/dist/src/utils/python.js +2 -2
  83. package/dist/src/utils/python.js.map +1 -1
  84. package/docs/BRANCHES.md +37 -0
  85. package/docs/CONTACT_DB_SCHEMA.md +1085 -0
  86. package/docs/DECISIONS.md +1624 -0
  87. package/docs/EXTENDING.md +98 -10
  88. package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +6 -0
  89. package/docs/HEALTH-CHECK.md +12 -7
  90. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +2 -2
  91. package/docs/MCP.md +58 -4
  92. package/docs/PROJECT_STATE.md +180 -11
  93. package/docs/RELEASING.md +115 -1
  94. package/docs/images/weflow-architecture-gpt-image-2.png +0 -0
  95. package/logo.png +0 -0
  96. package/mcp-server/http.ts +108 -0
  97. package/mcp-server/httpConfig.ts +126 -0
  98. package/mcp-server/index.ts +359 -681
  99. package/package.json +5 -3
  100. package/resources/js/graph3d/NOTICE.txt +35 -0
  101. package/resources/js/graph3d/d3-binarytree.min.js +2 -0
  102. package/resources/js/graph3d/d3-dispatch.min.js +2 -0
  103. package/resources/js/graph3d/d3-force-3d.min.js +2 -0
  104. package/resources/js/graph3d/d3-octree.min.js +2 -0
  105. package/resources/js/graph3d/d3-quadtree.min.js +2 -0
  106. package/resources/js/graph3d/d3-timer.min.js +2 -0
  107. package/resources/js/graph3d/layout.mjs +63 -0
  108. package/resources/js/graph3d/three.min.js +6 -0
  109. package/resources/js/graph3d/viewer2d.js +448 -0
  110. package/resources/panel/ball-position.cjs +48 -2
  111. package/resources/panel/index.html +18 -4
  112. package/resources/panel/main.cjs +137 -5
  113. package/resources/panel/mascot-base.png +0 -0
  114. package/resources/panel/mascot-blink-1.png +0 -0
  115. package/resources/panel/mascot-blink-2.png +0 -0
  116. package/resources/panel/mascot-iris.png +0 -0
  117. package/resources/panel/mascot-lift-down-1.png +0 -0
  118. package/resources/panel/mascot-lift-down-2.png +0 -0
  119. package/resources/panel/mascot-lift-down-3.png +0 -0
  120. package/resources/panel/mascot-lift-down-4.png +0 -0
  121. package/resources/panel/mascot-lift-down-5.png +0 -0
  122. package/resources/panel/mascot-lift-down-6.png +0 -0
  123. package/resources/panel/mascot-lift-up-1.png +0 -0
  124. package/resources/panel/mascot-lift-up-2.png +0 -0
  125. package/resources/panel/mascot-lift-up-3.png +0 -0
  126. package/resources/panel/mascot-lift-up-4.png +0 -0
  127. package/resources/panel/mascot-lift-up-5.png +0 -0
  128. package/resources/panel/mascot-lift-up-6.png +0 -0
  129. package/resources/panel/mascot-peek.png +0 -0
  130. package/resources/panel/mascot-tickle-1.png +0 -0
  131. package/resources/panel/mascot-tickle-2.png +0 -0
  132. package/resources/panel/mascot-tickle-3.png +0 -0
  133. package/resources/panel/mascot-tickle-4.png +0 -0
  134. package/resources/panel/panel.css +205 -59
  135. package/resources/panel/preload.cjs +26 -0
  136. package/resources/panel/renderer.js +646 -17
  137. package/scripts/_utils.py +1203 -931
  138. package/scripts/backfill_articles.py +202 -21
  139. package/scripts/biz_daily.py +26 -34
  140. package/scripts/bonds.py +331 -0
  141. package/scripts/chat_notes.py +561 -470
  142. package/scripts/chat_stats.py +8 -7
  143. package/scripts/check_readme_commands.py +426 -0
  144. package/scripts/compile_wiki.py +187 -59
  145. package/scripts/contact_schema.py +339 -0
  146. package/scripts/data/wechat_emoji.json +225 -0
  147. package/scripts/export_chat_html.py +3 -1
  148. package/scripts/fav_notes.py +4 -1
  149. package/scripts/graph_2d.py +151 -0
  150. package/scripts/graph_3d.py +646 -0
  151. package/scripts/health_check.py +163 -26
  152. package/scripts/mcp_bridge.py +6 -7
  153. package/scripts/nt_decrypt.py +3 -3
  154. package/scripts/panel_frames.py +683 -0
  155. package/scripts/pipeline.py +21 -10
  156. package/scripts/quality_eval.py +5 -0
  157. package/scripts/rag_chat.py +33 -16
  158. package/scripts/reply_debt.py +26 -6
  159. package/scripts/semantic_search.py +130 -22
  160. package/scripts/vault_rag.py +42 -16
  161. package/scripts/vault_search.py +44 -8
  162. package/scripts/wechat_emoticon.py +6 -0
  163. package/scripts/wechat_voice.py +9 -0
  164. package/scripts/wiki_lint.py +124 -24
  165. package/src/core/dbPathService.ts +22 -4
  166. package/src/panel/server.ts +58 -1
  167. package/src/services/assistantEval.ts +323 -12
  168. package/src/services/assistantScenes.ts +342 -0
  169. package/src/services/assistantService.ts +107 -6
  170. package/src/services/assistantSkills.ts +364 -0
  171. package/src/services/assistantTools.ts +2452 -1640
  172. package/src/services/assistantTrace.ts +9 -0
  173. package/src/services/configService.ts +415 -403
  174. package/src/services/messageQuery.ts +29 -0
  175. package/src/services/outboundSend.ts +126 -0
  176. package/src/services/pythonBridge.ts +1 -0
  177. package/src/services/wechatClient.ts +380 -0
  178. package/src/services/wechatMedia.ts +166 -0
  179. package/src/services/wechatMessageService.ts +62 -11
  180. package/src/types.ts +4 -0
  181. package/src/utils/python.ts +2 -2
  182. package/dist/src/utils/wechatEmoji.d.ts +0 -5
  183. package/dist/src/utils/wechatEmoji.d.ts.map +0 -1
  184. package/dist/src/utils/wechatEmoji.js +0 -35
  185. package/dist/src/utils/wechatEmoji.js.map +0 -1
@@ -0,0 +1,364 @@
1
+ /**
2
+ * 助手能用的技能包(Skills)—— 发现、解析、四态降级、正文读取。
3
+ *
4
+ * **技能是内容,不是代码**:本模块只把 `SKILL.md` 当**资料**交给模型(给路径、给正文),
5
+ * 不执行其中任何东西、不给模型增加任何新工具。这是 P4「插件/适配器机制」验收标准的
6
+ * 保守落地:「第三方扩展无法直接读取配置、数据库或任意文件;扩展能被单独禁用」。
7
+ * 想给助手加能力要走 `docs/EXTENDING.md` 的 Recipe A(加工具),不是往技能里塞脚本。
8
+ *
9
+ * 口径来源:技能格式与 Anthropic Agent Skills 一致(`name` + `description` frontmatter),
10
+ * 我们只**读**它,不发明新格式 —— 这样用户已经装在 `~/.claude/skills` 下的技能直接可用。
11
+ *
12
+ * 三个实测得来的硬约束(别按直觉改):
13
+ * 1. **只解析文件开头那一段 `---`**。实测用户的技能正文里也有 `categories:`/`created:`/
14
+ * `tags:` 这些行(`memory-organizer`、`obsidian-wiki-workflow`),扫全文会把它们当字段读进来。
15
+ * 2. **必须处理 YAML 块标量**。真实技能里 `description: >-` / `description: |` 后面跟着缩进的
16
+ * 多行正文,不处理的话 description 就变成 `>-` 两个字。
17
+ * 3. **ID 规范要比 Anthropic 宽**。他们的 `^[a-z0-9]+(-[a-z0-9]+)*$` 会把用户自己的
18
+ * `clz_docx_to_mp`、`agent-dialog_management`、`ylx_onehub_usage_monitor` 判为不合规 ——
19
+ * 那些是真实在用的技能,所以不合规只**警告**,不丢弃。
20
+ */
21
+ import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
22
+ import { join } from 'path'
23
+ import { homedir } from 'os'
24
+ import { configService } from './configService.js'
25
+ import { expandHomePath } from '../utils/pathUtils.js'
26
+
27
+ /** 技能正文一次最多交给模型多少字(`read_skill`):足够长,又不至于把上下文吃掉 */
28
+ export const SKILL_BODY_LIMIT = 8192
29
+
30
+ /**
31
+ * 我们接受的 ID:小写字母、数字、点、下划线、连字符 —— 比 Anthropic 规范宽(见文件头第 3 条)。
32
+ */
33
+ const ID_RE = /^[a-z0-9][a-z0-9._-]*$/
34
+ /**
35
+ * Anthropic Agent Skills 的严格规范。**只用来说话,不用来拒绝**:
36
+ * 不合规的 ID 照样能用(用户的 `clz_docx_to_mp` 就在用),但 `skill check` 要讲出来,
37
+ * 免得他以为这些能直接搬去别的 Agent 用。
38
+ */
39
+ const SPEC_ID_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/
40
+
41
+ export interface SkillInfo {
42
+ /** 目录名,也是引用时用的 id */
43
+ id: string
44
+ /** 显示名:frontmatter 的 `name`,缺了就用目录名 */
45
+ name: string
46
+ description: string
47
+ version: string
48
+ /** `SKILL.md` 的绝对路径 —— 交给模型的就是它 */
49
+ file: string
50
+ /** 来自哪个根目录(同 id 冲突时靠前的胜出) */
51
+ root: string
52
+ /** 技能自己声明的 `enabled: false` */
53
+ selfDisabled: boolean
54
+ /** `triggers:` 列表,`list_skills --query` 会匹配它 */
55
+ triggers: string[]
56
+ /** 不合规 / 缺字段之类的提醒,只报不改 */
57
+ notes: string[]
58
+ }
59
+
60
+ export interface SkillScan {
61
+ skills: SkillInfo[]
62
+ /** 读不出 frontmatter 的 `SKILL.md`:路径 + 原因。**不静默跳过** */
63
+ unreadable: Array<{ file: string; reason: string }>
64
+ /** 同 id 出现在多个根:只报冲突,不覆盖 */
65
+ conflicts: Array<{ id: string; winner: string; loser: string }>
66
+ /** 根本没读到的根目录(配错了会表现为"一个技能都没有") */
67
+ missingRoots: string[]
68
+ }
69
+
70
+ export type SkillState = 'ready' | 'disabled' | 'missing' | 'unknown'
71
+
72
+ // ---------- 根目录 ----------
73
+
74
+ /**
75
+ * 技能根目录清单。优先级:环境变量 > 配置键 > 默认两个根。
76
+ *
77
+ * 默认两个根是有意的:`~/.claude/skills` 让用户**已有的**技能立刻可用,
78
+ * `~/.weflow-cli/skills` 是本项目自己的落脚点(现在多半是空的)。
79
+ *
80
+ * 注意 `configService.get()` 只展开**开头**那个 `~`,逗号分隔的清单里第二个 `~` 它不管,
81
+ * 所以这里逐段自己调 `expandHomePath`(踩过:不展开会让第二个根静默不存在)。
82
+ */
83
+ export function skillRoots(): string[] {
84
+ const env = String(process.env.WEFLOW_ASSISTANT_SKILL_DIRS || '').trim()
85
+ const raw = env || String(configService.get('skillDirs') || '').trim()
86
+ if (raw) {
87
+ return raw.split(/[,;]/)
88
+ .map(part => expandHomePath(part.trim().replace(/^["']|["']$/g, '')))
89
+ .filter(Boolean)
90
+ }
91
+ return [join(homedir(), '.claude', 'skills'), join(homedir(), '.weflow-cli', 'skills')]
92
+ }
93
+
94
+ /** 配置键 `skillDisabled`:用户侧的禁用名单(技能自己也能声明 `enabled: false`) */
95
+ export function disabledIds(): string[] {
96
+ const raw = String(configService.get('skillDisabled') || '').trim()
97
+ if (!raw) return []
98
+ return raw.split(/[,;]/).map(part => part.trim()).filter(Boolean)
99
+ }
100
+
101
+ // ---------- frontmatter ----------
102
+
103
+ interface ParsedFrontmatter {
104
+ fields: Record<string, string>
105
+ lists: Record<string, string[]>
106
+ }
107
+
108
+ /**
109
+ * 只解析开头那一段 `---`。返回 null 表示**这一段不成立**(不是"没有字段")——
110
+ * 调用方要把这种情况报出来,不要当成"这个技能没写描述"。
111
+ */
112
+ export function parseFrontmatter(text: string): ParsedFrontmatter | null {
113
+ const t = String(text || '').replace(/^/, '')
114
+ const lines = t.split(/\r?\n/)
115
+ if (!/^---[ \t]*$/.test(lines[0] || '')) return null
116
+ let end = -1
117
+ for (let i = 1; i < lines.length; i += 1) {
118
+ if (/^---[ \t]*$/.test(lines[i])) { end = i; break }
119
+ }
120
+ if (end === -1) return null // 开头有 --- 却没有收尾:残缺,宁可报错也不猜
121
+
122
+ const fields: Record<string, string> = {}
123
+ const lists: Record<string, string[]> = {}
124
+ let blockKey: string | null = null // 正在收的块标量(`description: >-` 这类)
125
+ let blockMode: 'folded' | 'literal' = 'folded'
126
+ let blockLines: string[] = []
127
+ let listKey: string | null = null // 正在收的列表(`triggers:` 这类)
128
+
129
+ const closeBlock = () => {
130
+ if (!blockKey) return
131
+ // `>` 折行(空格连接),`|` 保留换行。`-` 后缀(`>-`/`|-`)表示去掉末尾换行,这里一并处理。
132
+ fields[blockKey] = (blockMode === 'literal' ? blockLines.join('\n') : blockLines.join(' ')).trim()
133
+ blockKey = null
134
+ blockLines = []
135
+ }
136
+ const closeList = () => {
137
+ if (listKey && lists[listKey]?.length === 0) lists[listKey] = []
138
+ listKey = null
139
+ }
140
+
141
+ for (let i = 1; i < end; i += 1) {
142
+ const raw = lines[i]
143
+ const line = raw.replace(/\s+$/, '')
144
+ if (!line.trim() || /^\s*#/.test(line)) continue
145
+
146
+ const indented = /^\s/.test(line)
147
+ if (indented) {
148
+ const item = /^\s+-\s*(.*)$/.exec(line)
149
+ if (item && listKey) { lists[listKey].push(stripQuotes(item[1].trim())); continue }
150
+ if (blockKey) { blockLines.push(line.replace(/^\s+/, '')); continue }
151
+ continue // 嵌套映射(如 `metadata:` 下缩进的 `type: skill`)——只认扁平的,嵌套的忽略
152
+ }
153
+
154
+ closeBlock()
155
+ closeList()
156
+
157
+ const m = /^([A-Za-z][A-Za-z0-9_.-]*)\s*:\s*(.*)$/.exec(line)
158
+ if (!m) continue
159
+ const key = m[1]
160
+ const value = m[2].trim()
161
+
162
+ if (/^[|>][-+]?$/.test(value)) {
163
+ blockKey = key
164
+ blockMode = value.startsWith('|') ? 'literal' : 'folded'
165
+ continue
166
+ }
167
+ if (value === '') {
168
+ // 空值:后面若是缩进的 `- ` 就是列表,否则就是个空字段
169
+ lists[key] = lists[key] || []
170
+ listKey = key
171
+ fields[key] = ''
172
+ continue
173
+ }
174
+ fields[key] = stripQuotes(value)
175
+ }
176
+ closeBlock()
177
+ closeList()
178
+ return { fields, lists }
179
+ }
180
+
181
+ function stripQuotes(value: string): string {
182
+ return String(value || '').replace(/^["']|["']$/g, '')
183
+ }
184
+
185
+ // ---------- 扫描 ----------
186
+
187
+ export function scanSkills(roots = skillRoots()): SkillScan {
188
+ const skills: SkillInfo[] = []
189
+ const unreadable: SkillScan['unreadable'] = []
190
+ const conflicts: SkillScan['conflicts'] = []
191
+ const missingRoots: string[] = []
192
+ const seen = new Map<string, string>()
193
+
194
+ for (const root of roots) {
195
+ let entries: string[] = []
196
+ try {
197
+ if (!existsSync(root) || !statSync(root).isDirectory()) { missingRoots.push(root); continue }
198
+ entries = readdirSync(root)
199
+ } catch {
200
+ missingRoots.push(root)
201
+ continue
202
+ }
203
+ for (const entry of entries) {
204
+ const file = join(root, entry, 'SKILL.md')
205
+ const dir = join(root, entry)
206
+ // 只认 `<root>/<id>/SKILL.md`。**符号链接是跟的**(有意):在 git 仓库里开发技能、
207
+ // 再 `ln -s` 到技能根目录是常见做法,拒掉会让人觉得"我的技能怎么不见了"。
208
+ // 软链是用户自己放的(扩展造不出来),而 `read_skill` 只可能读到名字叫 `SKILL.md`
209
+ // 的文件,所以这条不是一个可被扩展利用的越界口子。
210
+ try {
211
+ if (!statSync(dir).isDirectory()) continue
212
+ if (!existsSync(file) || !statSync(file).isFile()) continue
213
+ } catch {
214
+ continue
215
+ }
216
+ let text = ''
217
+ try {
218
+ text = readFileSync(file, 'utf8')
219
+ } catch (error) {
220
+ unreadable.push({ file, reason: `读不了:${(error as Error).message}` })
221
+ continue
222
+ }
223
+ const fm = parseFrontmatter(text)
224
+ if (!fm) {
225
+ unreadable.push({ file, reason: '开头的 --- frontmatter 段缺失或不闭合' })
226
+ continue
227
+ }
228
+ const notes: string[] = []
229
+ const id = entry
230
+ if (!ID_RE.test(id)) {
231
+ // 连我们这套宽规则都不满足(大写、空格、中文…):仍然保留,但要说出来
232
+ notes.push('ID 含非规范字符(不是小写字母/数字/点/下划线/连字符),本机可用,但别的 Agent 可能不认')
233
+ } else if (!SPEC_ID_RE.test(id)) {
234
+ // 实测用户有 3 个这种 ID(clz_docx_to_mp 等)。可用,但搬去别的 Agent 会不合规。
235
+ notes.push('ID 不符合 Anthropic Agent Skills 规范(只允许小写字母/数字/单个连字符),本机可用,搬去别的 Agent 可能不认')
236
+ }
237
+ const description = String(fm.fields.description || '').trim()
238
+ if (!description) notes.push('frontmatter 里没有 description —— 模型只能靠它判断这个技能是干什么的')
239
+
240
+ if (seen.has(id)) {
241
+ conflicts.push({ id, winner: seen.get(id)!, loser: file })
242
+ continue // 靠前的根胜出;后一个只报冲突,不覆盖
243
+ }
244
+ seen.set(id, file)
245
+ skills.push({
246
+ id,
247
+ name: String(fm.fields.name || id).trim() || id,
248
+ description,
249
+ version: String(fm.fields.version || '').trim(),
250
+ file,
251
+ root,
252
+ selfDisabled: String(fm.fields.enabled || '').trim().toLowerCase() === 'false',
253
+ triggers: fm.lists.triggers || [],
254
+ notes,
255
+ })
256
+ }
257
+ }
258
+ skills.sort((a, b) => a.id.localeCompare(b.id))
259
+ return { skills, unreadable, conflicts, missingRoots }
260
+ }
261
+
262
+ export function findSkill(id: string, scan: SkillScan = scanSkills()): SkillInfo | undefined {
263
+ return scan.skills.find(s => s.id === id)
264
+ }
265
+
266
+ // ---------- 四态降级 ----------
267
+
268
+ export function skillState(skill: SkillInfo, disabled = disabledIds()): SkillState {
269
+ if (skill.selfDisabled || disabled.includes(skill.id)) return 'disabled'
270
+ return 'ready'
271
+ }
272
+
273
+ /**
274
+ * 把技能渲染成给模型的一句话。四态,每一态都**说实话**:
275
+ * 缺文件不阻断(照 WeChatBridge 的做法:继续做,但要求模型在回答里说明这块没做)。
276
+ */
277
+ export function describeSkill(id: string, scan: SkillScan = scanSkills()): { state: SkillState; line: string } {
278
+ const skill = findSkill(id, scan)
279
+ if (!skill) {
280
+ return {
281
+ state: 'missing',
282
+ line: `技能「${id}」不可用(本机没有这个技能),请直接完成,并在回答里说明哪部分没做。`,
283
+ }
284
+ }
285
+ const state = skillState(skill)
286
+ if (state === 'disabled') {
287
+ return { state, line: `技能「${skill.name}」已被用户禁用,不要使用它。` }
288
+ }
289
+ return {
290
+ state,
291
+ line: `技能「${skill.name}」:说明文件在 \`${skill.file}\`,请先读它再严格按其执行。`,
292
+ }
293
+ }
294
+
295
+ /**
296
+ * 解析 `{{skill:<id>}}` 行内引用。**只认这一种形式**,别的 `{{...}}` 原样保留
297
+ * (本仓没有模板引擎,不要让人以为这是通用的)。
298
+ * 未知 id **不静默删掉** —— 显式告诉模型"你引用了不存在的技能"。
299
+ */
300
+ export function renderSkillRefs(text: string, scan: SkillScan = scanSkills()): string {
301
+ return String(text || '').replace(/\{\{skill:([^}]+)\}\}/g, (_all, rawId: string) => {
302
+ const id = rawId.trim()
303
+ const { line } = describeSkill(id, scan)
304
+ return line
305
+ })
306
+ }
307
+
308
+ /** 一个场景/一段文本实际引用了哪些技能:行内引用 ∪ 显式声明(去重、保序) */
309
+ export function referencedSkillIds(text: string, declared: string[] = []): string[] {
310
+ const ids: string[] = []
311
+ const push = (id: string) => { if (id && !ids.includes(id)) ids.push(id) }
312
+ for (const m of String(text || '').matchAll(/\{\{skill:([^}]+)\}\}/g)) push(m[1].trim())
313
+ for (const id of declared) push(String(id || '').trim())
314
+ return ids
315
+ }
316
+
317
+ // ---------- 正文读取(给工具用,路径限界是硬要求) ----------
318
+
319
+ export interface SkillBody { ok: boolean; text: string; truncated?: boolean }
320
+
321
+ /**
322
+ * 读某个技能的 `SKILL.md` 正文。
323
+ *
324
+ * **路径限界**:只从扫描结果里取路径,绝不接受调用方拼出来的路径。也就是说
325
+ * `read_skill('../config.json')` 这类根本走不到文件系统 —— 因为 `../config.json`
326
+ * 不是任何一个扫描出来的技能 id。这条是 P4 验收标准的现场,有测试逐个盯着。
327
+ */
328
+ export function readSkillBody(id: string, scan: SkillScan = scanSkills()): SkillBody {
329
+ const skill = findSkill(id, scan)
330
+ if (!skill) return { ok: false, text: `本机没有技能「${id}」。可以用 list_skills 看有哪些。` }
331
+ if (skillState(skill) === 'disabled') {
332
+ return { ok: false, text: `技能「${skill.id}」已被禁用,不读取它的内容。` }
333
+ }
334
+ let text = ''
335
+ try {
336
+ text = readFileSync(skill.file, 'utf8')
337
+ } catch (error) {
338
+ return { ok: false, text: `技能「${skill.id}」的说明文件读不了:${(error as Error).message}` }
339
+ }
340
+ const truncated = text.length > SKILL_BODY_LIMIT
341
+ return {
342
+ ok: true,
343
+ text: truncated ? `${text.slice(0, SKILL_BODY_LIMIT)}\n\n…(正文过长,已截断到 ${SKILL_BODY_LIMIT} 字)` : text,
344
+ truncated,
345
+ }
346
+ }
347
+
348
+ // ---------- 提示词目录 ----------
349
+
350
+ /** 只列名字 + 描述 + 状态。27 个技能的正文全塞进每轮提示词是浪费,正文按需 `read_skill`。 */
351
+ export function skillCatalogueLines(scan: SkillScan = scanSkills(), limit = 40): string[] {
352
+ const disabled = disabledIds()
353
+ const usable = scan.skills.filter(s => skillState(s, disabled) === 'ready')
354
+ const lines = usable.slice(0, limit).map(s => {
355
+ const desc = s.description.length > 80 ? `${s.description.slice(0, 80)}…` : s.description
356
+ return `· ${s.id}(${s.name}):${desc || '(无描述)'}`
357
+ })
358
+ const hidden = usable.length - lines.length
359
+ const off = scan.skills.length - usable.length
360
+ const notes: string[] = []
361
+ if (hidden > 0) notes.push(`另有 ${hidden} 个技能未列出`)
362
+ if (off > 0) notes.push(`${off} 个技能被禁用`)
363
+ return notes.length ? [...lines, `(${notes.join(';')})`] : lines
364
+ }