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,342 @@
1
+ /**
2
+ * 场景(Scenes)—— 按会话生效的提示词预设。
3
+ *
4
+ * 一个场景 = 名称 + 关键词 + 附加指令 + 输出规范 + 所需技能。消息进来时按三档挑一个:
5
+ * **绑定 → 关键词 → 上次用过的**,都没有就不加场景段(默认行为一字不变)。
6
+ *
7
+ * 为什么单独一个文件、不塞进配置键、也不塞进记忆文件:
8
+ * - 配置键是字符串,多字段结构化内容塞进去就等于让用户手写 JSON;
9
+ * - 记忆文件会被 `compressIfNeeded`/`extractFactsIfNeeded` 用 LLM 改写,还有 `quarantine()`
10
+ * 把整个文件搬走的路径 —— **用户手写的场景不该走那条路**(被模型改写或被隔离都不像话)。
11
+ *
12
+ * 存取纪律照抄记忆文件:版本号 + 未知版本**留档不猜** + `writeFileAtomic` + 只回写脏数据 +
13
+ * `loadIssue`/`saveIssue` 不抛但必记。
14
+ *
15
+ * **场景不给模型碰**:没有任何模型可调的工具能改场景。配置类的东西只能用户显式改
16
+ * (同 `隐私` 那条注释的立场)。所以这个类只有 CLI 和内置指令两个调用方。
17
+ */
18
+ import { existsSync, mkdirSync, readFileSync, renameSync } from 'fs'
19
+ import { homedir } from 'os'
20
+ import { join } from 'path'
21
+ import { writeFileAtomic } from '../utils/atomicWrite.js'
22
+ import { frameLocalData } from './assistantMemory.js'
23
+ import { renderSkillRefs, referencedSkillIds, describeSkill } from './assistantSkills.js'
24
+
25
+ export const SCENES_FORMAT_VERSION = 1
26
+ const SCENES_FILE = join(homedir(), '.weflow-cli', 'assistant_scenes.json')
27
+
28
+ /** 保留字:`场景 无` 用来解绑,所以不能拿它当 id */
29
+ export const SCENE_RESERVED_IDS = ['无', 'none', 'off']
30
+
31
+ export interface Scene {
32
+ id: string
33
+ name: string
34
+ /** 消息里出现任一个就命中(不区分大小写) */
35
+ keywords: string[]
36
+ /** 附加指令,可以写 `{{skill:<id>}}` */
37
+ instruction: string
38
+ /** 输出规范(可选) */
39
+ outputSpec: string
40
+ /** 需要的技能 id —— 与指令里的行内引用取并集 */
41
+ skills: string[]
42
+ enabled: boolean
43
+ }
44
+
45
+ export type MatchHow = 'binding' | 'keyword' | 'lastUsed'
46
+ export interface SceneMatch { scene: Scene; how: MatchHow }
47
+
48
+ interface ScenesFile {
49
+ version: number
50
+ scenes: Scene[]
51
+ /** 会话(userId)→ 场景 id。微信那条路上 userId 就是 conversationId */
52
+ bindings: Record<string, string>
53
+ /** 每个会话上一次实际用了哪个场景(没绑定时的兜底) */
54
+ lastUsed: Record<string, string>
55
+ }
56
+
57
+ function normalizeScene(raw: any): Scene | null {
58
+ if (!raw || typeof raw !== 'object') return null
59
+ const id = String(raw.id || '').trim()
60
+ if (!id) return null
61
+ const asList = (v: any): string[] => Array.isArray(v)
62
+ ? v.map((x: any) => String(x || '').trim()).filter(Boolean)
63
+ : []
64
+ return {
65
+ id,
66
+ name: String(raw.name || id).trim() || id,
67
+ keywords: asList(raw.keywords),
68
+ instruction: typeof raw.instruction === 'string' ? raw.instruction : '',
69
+ outputSpec: typeof raw.outputSpec === 'string' ? raw.outputSpec : '',
70
+ skills: asList(raw.skills),
71
+ // 缺省是"开"。只有显式 false 才算关(老数据没有这个字段)
72
+ enabled: raw.enabled === undefined ? true : raw.enabled !== false,
73
+ }
74
+ }
75
+
76
+ /**
77
+ * id 规则:**允许中文**(这是给中文用户用的,`场景 日报` 比 `场景 daily-report` 自然得多),
78
+ * 但禁掉空白、路径分隔符、以及引号和尖括号。
79
+ *
80
+ * 引号尖括号不是洁癖:id 会出现在 `frameLocalData('scene.<id>', …)` 的
81
+ * `source="…"` 属性里(见 `assistantMemory.frameLocalData`,它只转义**正文**里的框标签,
82
+ * 不转义标签),带 `"` 的 id 能把属性闭合掉。这里挡住,`safeSceneLabel` 再兜一层。
83
+ */
84
+ const SCENE_ID_BANNED = /[\s/\\:*?"'<>|]/
85
+
86
+ export function validateSceneId(id: string): string | null {
87
+ const v = String(id || '').trim()
88
+ if (!v) return '场景 id 不能为空'
89
+ if (v.length > 40) return '场景 id 太长(40 字以内)'
90
+ if (SCENE_ID_BANNED.test(v)) return '场景 id 不能含空白、路径分隔符、引号或尖括号'
91
+ if (v.startsWith('.')) return '场景 id 不能以点开头'
92
+ if (SCENE_RESERVED_IDS.includes(v.toLowerCase())) return `「${v}」是保留字(用来解绑),换一个 id`
93
+ return null
94
+ }
95
+
96
+ /** 把 id 放进提示词标签前再洗一遍(纵深防御:校验是一道,这里是第二道) */
97
+ function safeSceneLabel(id: string): string {
98
+ return String(id || '').replace(/[^0-9A-Za-z\u0080-￿._-]/g, '_').slice(0, 40)
99
+ }
100
+
101
+ export class AssistantScenes {
102
+ private scenes: Scene[] = []
103
+ private bindings: Record<string, string> = {}
104
+ private lastUsed: Record<string, string> = {}
105
+ private dirty = false
106
+ private loadIssue = ''
107
+ private saveIssue: string | null = null
108
+
109
+ constructor() { this.load() }
110
+
111
+ get problem(): string { return this.loadIssue }
112
+ get lastSaveError(): string | null { return this.saveIssue }
113
+
114
+ /** 把读不出来的文件改名留档——用户手写的场景,**不许静默丢弃** */
115
+ private quarantine(reason: string): void {
116
+ const target = `${SCENES_FILE}.unreadable-${new Date().toISOString().replace(/[:.]/g, '-')}`
117
+ try {
118
+ renameSync(SCENES_FILE, target)
119
+ this.loadIssue = `${reason};原文件已留档为 ${target.split(/[\\/]/).pop()}`
120
+ } catch (error: any) {
121
+ this.loadIssue = `${reason};留档也失败了(${error?.message ?? error}),未改动原文件`
122
+ }
123
+ this.scenes = []
124
+ this.bindings = {}
125
+ this.lastUsed = {}
126
+ }
127
+
128
+ private load(): void {
129
+ if (!existsSync(SCENES_FILE)) return
130
+ let raw: any
131
+ try {
132
+ raw = JSON.parse(readFileSync(SCENES_FILE, 'utf8'))
133
+ } catch (error: any) {
134
+ this.quarantine(`场景文件无法解析(${error?.message?.slice(0, 80) ?? error})`)
135
+ return
136
+ }
137
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
138
+ this.quarantine('场景文件不是一个对象')
139
+ return
140
+ }
141
+ if (raw.version !== SCENES_FORMAT_VERSION) {
142
+ // 不猜、不迁移(记忆文件同款纪律)
143
+ this.quarantine(`场景文件版本是 ${JSON.stringify(raw.version)},本程序只认 ${SCENES_FORMAT_VERSION}`)
144
+ return
145
+ }
146
+ const list = Array.isArray(raw.scenes) ? raw.scenes : []
147
+ const seen = new Set<string>()
148
+ for (const item of list) {
149
+ const scene = normalizeScene(item)
150
+ if (!scene) continue
151
+ if (seen.has(scene.id)) continue // 文件被手改出重复 id:留第一个,不崩
152
+ seen.add(scene.id)
153
+ this.scenes.push(scene)
154
+ }
155
+ const asMap = (v: any): Record<string, string> => {
156
+ if (!v || typeof v !== 'object' || Array.isArray(v)) return {}
157
+ const out: Record<string, string> = {}
158
+ for (const [k, val] of Object.entries<any>(v)) {
159
+ const s = String(val || '').trim()
160
+ if (s) out[k] = s
161
+ }
162
+ return out
163
+ }
164
+ this.bindings = asMap(raw.bindings)
165
+ this.lastUsed = asMap(raw.lastUsed)
166
+ }
167
+
168
+ save(): void {
169
+ try {
170
+ const dir = join(homedir(), '.weflow-cli')
171
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
172
+ const payload: ScenesFile = {
173
+ version: SCENES_FORMAT_VERSION,
174
+ scenes: this.scenes,
175
+ bindings: this.bindings,
176
+ lastUsed: this.lastUsed,
177
+ }
178
+ writeFileAtomic(SCENES_FILE, JSON.stringify(payload, null, 1))
179
+ this.dirty = false
180
+ this.saveIssue = null
181
+ } catch (error: any) {
182
+ // 不抛:磁盘打嗝不该让一次 CLI 操作炸掉。但也不吭声就不对了 —— 用户以为自己加上了。
183
+ this.saveIssue = String(error?.message ?? error).slice(0, 120)
184
+ }
185
+ }
186
+
187
+ // ---------- 读 ----------
188
+
189
+ list(): Scene[] { return [...this.scenes] }
190
+ get(id: string): Scene | undefined { return this.scenes.find(s => s.id === id) }
191
+ binding(userId: string): string { return this.bindings[userId] || '' }
192
+ lastUsedFor(userId: string): string { return this.lastUsed[userId] || '' }
193
+
194
+ // ---------- 写(都只改内存,由调用方决定何时 save) ----------
195
+
196
+ add(scene: Scene): { ok: boolean; reason?: string } {
197
+ const idError = validateSceneId(scene.id)
198
+ if (idError) return { ok: false, reason: idError }
199
+ if (this.get(scene.id)) return { ok: false, reason: `场景「${scene.id}」已经存在(要改就先 remove)` }
200
+ this.scenes.push({ ...scene, name: scene.name || scene.id, enabled: scene.enabled !== false })
201
+ this.dirty = true
202
+ return { ok: true }
203
+ }
204
+
205
+ remove(id: string): boolean {
206
+ const before = this.scenes.length
207
+ this.scenes = this.scenes.filter(s => s.id !== id)
208
+ if (this.scenes.length === before) return false
209
+ // 顺手清理指向它的绑定/上次记录,免得留下一堆悬空 id
210
+ for (const [uid, sid] of Object.entries(this.bindings)) if (sid === id) delete this.bindings[uid]
211
+ for (const [uid, sid] of Object.entries(this.lastUsed)) if (sid === id) delete this.lastUsed[uid]
212
+ this.dirty = true
213
+ return true
214
+ }
215
+
216
+ setEnabled(id: string, on: boolean): boolean {
217
+ const scene = this.get(id)
218
+ if (!scene) return false
219
+ scene.enabled = on
220
+ this.dirty = true
221
+ return true
222
+ }
223
+
224
+ bind(userId: string, sceneId: string | null): { ok: boolean; reason?: string } {
225
+ if (sceneId === null) {
226
+ delete this.bindings[userId]
227
+ this.dirty = true
228
+ return { ok: true }
229
+ }
230
+ const scene = this.get(sceneId)
231
+ if (!scene) return { ok: false, reason: `没有场景「${sceneId}」` }
232
+ if (!scene.enabled) return { ok: false, reason: `场景「${sceneId}」已被停用,先启用再绑` }
233
+ this.bindings[userId] = sceneId
234
+ this.dirty = true
235
+ return { ok: true }
236
+ }
237
+
238
+ noteUsed(userId: string, sceneId: string): void {
239
+ if (this.lastUsed[userId] === sceneId) return
240
+ this.lastUsed[userId] = sceneId
241
+ this.dirty = true
242
+ }
243
+
244
+ // ---------- 三档匹配 ----------
245
+
246
+ /**
247
+ * 挑一个场景。**只认唯一**:多个场景命中同样长的关键词时**拒绝**(返回 null + 理由),
248
+ * 不猜 —— 与本仓 `resolveUniqueTalker`、`resolvePanelUserId` 是同一条纪律。
249
+ */
250
+ resolve(userId: string, text: string, opts: { remember?: boolean } = {}): { match: SceneMatch | null; why?: string } {
251
+ const enabled = this.scenes.filter(s => s.enabled)
252
+ if (!enabled.length) return { match: null }
253
+
254
+ const boundId = this.bindings[userId]
255
+ if (boundId) {
256
+ const scene = enabled.find(s => s.id === boundId)
257
+ if (scene) return this.land(userId, { scene, how: 'binding' }, opts)
258
+ }
259
+
260
+ const hay = String(text || '').toLowerCase()
261
+ if (hay) {
262
+ // 每个场景取它**最长**的那个命中关键词;比长度,不比关键词个数
263
+ const hits: Array<{ scene: Scene; len: number; keyword: string }> = []
264
+ for (const scene of enabled) {
265
+ let best: { len: number; keyword: string } | null = null
266
+ for (const kw of scene.keywords) {
267
+ const k = kw.toLowerCase()
268
+ if (!k || !hay.includes(k)) continue
269
+ if (!best || k.length > best.len) best = { len: k.length, keyword: kw }
270
+ }
271
+ if (best) hits.push({ scene, len: best.len, keyword: best.keyword })
272
+ }
273
+ if (hits.length) {
274
+ const top = Math.max(...hits.map(h => h.len))
275
+ const winners = hits.filter(h => h.len === top)
276
+ if (winners.length === 1) {
277
+ return this.land(userId, { scene: winners[0].scene, how: 'keyword' }, opts)
278
+ }
279
+ const names = winners.map(w => `「${w.scene.name}」(关键词 ${w.keyword})`).join('、')
280
+ return { match: null, why: `有 ${winners.length} 个场景同样匹配:${names} —— 这次不带场景(避免猜错),可用「场景 <id>」固定一个` }
281
+ }
282
+ }
283
+
284
+ const lastId = this.lastUsed[userId]
285
+ if (lastId) {
286
+ const scene = enabled.find(s => s.id === lastId)
287
+ if (scene) return this.land(userId, { scene, how: 'lastUsed' }, opts)
288
+ }
289
+ return { match: null }
290
+ }
291
+
292
+ private land(userId: string, match: SceneMatch, opts: { remember?: boolean }): { match: SceneMatch } {
293
+ if (opts.remember !== false && this.lastUsed[userId] !== match.scene.id) {
294
+ this.lastUsed[userId] = match.scene.id
295
+ this.dirty = true
296
+ }
297
+ return { match }
298
+ }
299
+ }
300
+
301
+ /**
302
+ * 渲染一个场景成提示词里的一段。整段用 `frameLocalData` 包住 ——
303
+ * 指令和技能说明都是**本地磁盘上的内容**,可能含 `</weflow-local-data>` 想越狱。
304
+ */
305
+ export function renderScene(scene: Scene): string {
306
+ const parts: string[] = []
307
+ const instruction = renderSkillRefs(scene.instruction)
308
+ if (instruction.trim()) parts.push(instruction.trim())
309
+ if (scene.outputSpec.trim()) parts.push(`输出规范:\n${scene.outputSpec.trim()}`)
310
+ // 显式声明的技能(行内 {{skill:}} 已经在 instruction 里就地渲染过了,这里只补没提到的那些)
311
+ const declared = referencedSkillIds('', scene.skills)
312
+ const inline = referencedSkillIds(scene.instruction, [])
313
+ const rest = declared.filter(id => !inline.includes(id))
314
+ if (rest.length) {
315
+ parts.push(['技能要求:', ...rest.map(id => `· ${describeSkill(id).line}`)].join('\n'))
316
+ }
317
+ return frameLocalData(`scene.${safeSceneLabel(scene.id)}`, parts.join('\n\n'))
318
+ }
319
+
320
+ export interface SceneCommand {
321
+ kind: 'list' | 'bind' | 'unbind'
322
+ id?: string
323
+ }
324
+
325
+ /**
326
+ * 解析 `场景` 指令。**只有后半段真的是已存在的场景 id 才算指令**,
327
+ * 否则返回 null 交给模型 —— 不然「场景切换怎么用」这种正常问话会被当成命令吃掉。
328
+ * (现有内置指令都是全等匹配,这里是唯一一条带参数的,所以这条边界要写死。)
329
+ */
330
+ export function parseSceneCommand(text: string, scenes: AssistantScenes): SceneCommand | null {
331
+ const t = String(text || '').trim()
332
+ if (t === '场景') return { kind: 'list' }
333
+ const m = /^场景[\s:=]+(.+)$/.exec(t)
334
+ if (!m) return null
335
+ const arg = m[1].trim().replace(/^[「"']|[」"']$/g, '')
336
+ if (SCENE_RESERVED_IDS.includes(arg.toLowerCase())) return { kind: 'unbind' }
337
+ if (!scenes.get(arg)) return null // 不是真实场景 id → 不当指令
338
+ return { kind: 'bind', id: arg }
339
+ }
340
+
341
+ /** 全局单例:CLI 与助手共用一份(同 `configService`/`assistantMemory` 的做法) */
342
+ export const assistantScenes = new AssistantScenes()
@@ -22,6 +22,8 @@ import type { StopReason, TurnTrace } from './assistantTrace.js'
22
22
  import { appendLog } from './assistantDaemon.js'
23
23
  import type { Message, WechatInboundMessage } from '../types.js'
24
24
  import { buildEvidenceReviewInput } from './evidenceService.js'
25
+ import { scanSkills, skillCatalogueLines } from './assistantSkills.js'
26
+ import { assistantScenes, parseSceneCommand, renderScene, type Scene } from './assistantScenes.js'
25
27
  import { evaluateAssistantAccess } from './assistantRouting.js'
26
28
  import { nowLine } from '../utils/dateRange.js'
27
29
  import { resolvePanelUserId, PANEL_FALLBACK_BUCKET } from '../panel/userId.js'
@@ -317,14 +319,16 @@ export class AssistantService {
317
319
  + '占位符),可以直接引用,不必声称被屏蔽。'
318
320
  }
319
321
 
320
- /** 组装系统提示: 基础人格 + 隐私状态 + L2 摘要 + L3 事实。
322
+ /** 组装系统提示: 基础人格 + 隐私状态 + 本机能力 + 本会话场景 + L2 摘要 + L3 事实。
321
323
  *
322
- * 两个纪律:
324
+ * 三个纪律:
323
325
  * 1. **事实按相关度取一部分**,而不是 30 条全塞——无关的那些是噪声,不只是花钱;
324
- * 2. 本地数据(摘要、事实)一律**加帧 + 转义框标签**:它们的内容里完全可能写着
325
- * `</weflow-local-data>` 再跟一段像系统指令的话,不转义就等于让数据自己把框关上。
326
+ * 2. 本地数据(摘要、事实、场景、技能正文)一律**加帧 + 转义框标签**:它们的内容里完全
327
+ * 可能写着 `</weflow-local-data>` 再跟一段像系统指令的话,不转义就等于让数据自己把框关上;
328
+ * 3. **本机装了什么技能要说出来**(同「没启用的能力」那条的理由)——不说,用户问到时
329
+ * 模型会答"我没有这个能力",而它其实调 `read_skill` 就能拿到。
326
330
  */
327
- private buildSystemPrompt(userId: string, question = ''): string {
331
+ private buildSystemPrompt(userId: string, question = '', scene: Scene | null = null): string {
328
332
  // 当前时间:**没有它,任何相对时间都是猜**。"上周三""昨天""这周"要变成工具能用的
329
333
  // 日期,模型得先知道今天是几号(`get_messages` 的 since/until 就是这么用的)。
330
334
  const parts = [BASE_PROMPT, `[当前时间] ${nowLine()}(本机时区)`, this.privacyStateLine()]
@@ -337,6 +341,19 @@ export class AssistantService {
337
341
  + SEP + '(这些工具**不在你的工具表里**,但你调不到的原因只是没配置,**不是这个功能不存在**。'
338
342
  + '用户问到时直说"这块还没配置"并给出上面那个配置项,**不要说"我没有这个工具"**。)')
339
343
  }
344
+ // 本机装好的技能包。**只列名字/说明/版本,正文按需 read_skill 读** ——
345
+ // 27 个技能的正文全塞进每轮提示词,既贵又会把真正的问题挤掉。
346
+ const skillScan = scanSkills()
347
+ const skillLines = skillCatalogueLines(skillScan)
348
+ if (skillLines.length) {
349
+ parts.push('[本机已安装的技能]' + SEP + skillLines.join(SEP)
350
+ + SEP + '(技能是一份说明文档。要用某个技能,先用 `read_skill` 读它的正文再照着做;'
351
+ + '**不要凭名字猜它怎么用**。)')
352
+ }
353
+ const sceneLines = scene ? renderScene(scene) : ''
354
+ if (sceneLines) {
355
+ parts.push('[本会话的场景]' + SEP + `场景「${scene!.name}」已生效,按它的要求来做。` + SEP + sceneLines)
356
+ }
340
357
  const summary = this.memory.summary(userId)
341
358
  if (summary) {
342
359
  parts.push('[此前对话摘要]' + SEP + frameLocalData('memory.summary', summary))
@@ -359,6 +376,16 @@ export class AssistantService {
359
376
  private lastReasoning = ''
360
377
  /** 每个用户最近一轮的轨迹:微信里发「轨迹」看的就是它(跨进程的历史在文件里,见 assistantTrace) */
361
378
  private traces = new Map<string, TurnTrace>()
379
+
380
+ /**
381
+ * 最近一轮的轨迹,给**本机面板**用(`/api/ask` 回答完之后,面板把"思考过程"折叠着挂上去)。
382
+ *
383
+ * 与微信里那条「轨迹」是**同一个来源**(`this.traces`),所以两处不会各说各话。
384
+ * 只在本进程内存里:跨进程的历史在 `assistantTrace` 的文件里,面板要的是"刚刚那一轮"。
385
+ */
386
+ recentTrace(userId: string): TurnTrace | undefined {
387
+ return this.traces.get(userId)
388
+ }
362
389
  /**
363
390
  * 本轮已经调过的「工具名 + 参数」。同一条消息里重复调用**同样的参数**不会得到新信息,
364
391
  * 而实测(评测的 ambiguous-contact 用例)模型会连着调三次 list_sessions。
@@ -645,10 +672,52 @@ export class AssistantService {
645
672
  return lines.join('\n')
646
673
  }
647
674
 
675
+ // 场景:**命令要认,但只认真的存在的东西**。`parseSceneCommand` 只在后半段是已存在的
676
+ // 场景 id 时才当指令,所以「场景切换怎么用」这种问话不会被吃掉(见那边的注释)。
677
+ // 位置在内置指令之后、ReAct 之前:场景是这一轮的上下文,不是要模型去执行的任务。
678
+ const sceneCmd = parseSceneCommand(t, assistantScenes)
679
+ if (sceneCmd) {
680
+ if (sceneCmd.kind === 'list') {
681
+ const scenes = assistantScenes.list()
682
+ if (!scenes.length) {
683
+ return finish('还没有任何场景。用电脑加一个:\n'
684
+ + 'weflow-cli scene add --id 日报 --keywords 日报,今天推文 --instruction "按主题分组" --dry-run', 'builtin')
685
+ }
686
+ const active = assistantScenes.binding(userId)
687
+ const lines = scenes.map(s => `${s.id === active ? '▶ ' : ' '}${s.id}(${s.name})${s.enabled ? '' : '[已停用]'}`
688
+ + (s.keywords.length ? ` 关键词:${s.keywords.join('、')}` : ''))
689
+ lines.push('', `本会话当前:${active ? `绑定「${active}」` : '未绑定(按关键词或上次用过的挑)'}`)
690
+ lines.push('绑定:场景 <id> 解绑:场景 无')
691
+ return finish(lines.join(String.fromCharCode(10)), 'builtin')
692
+ }
693
+ if (sceneCmd.kind === 'unbind') {
694
+ assistantScenes.bind(userId, null)
695
+ assistantScenes.save()
696
+ return finish('✓ 已解绑,这个会话回到按关键词自动挑', 'builtin')
697
+ }
698
+ const bound = assistantScenes.bind(userId, sceneCmd.id as string)
699
+ if (!bound.ok) return finish(`✗ ${bound.reason}`, 'builtin')
700
+ assistantScenes.save()
701
+ const scene = assistantScenes.get(sceneCmd.id as string)
702
+ return finish(`✓ 已给这个会话绑定场景「${scene?.name ?? sceneCmd.id}」。之后每轮都按它来做,发「场景 无」解绑。`, 'builtin')
703
+ }
704
+
648
705
  // === ReAct 主循环 ===
649
706
  this.memory.addTurn(userId, 'user', t)
707
+ // 挑场景:绑定 → 关键词 → 上次用过的(都不中就不加场景段,默认行为一字不变)
708
+ const picked = assistantScenes.resolve(userId, t)
709
+ const scene = picked.match?.scene ?? null
710
+ if (picked.match) {
711
+ trace.steps.push({ kind: 'note', detail: `场景「${picked.match.scene.name}」· 命中:`
712
+ + ({ binding: '绑定', keyword: '关键词', lastUsed: '上次用过的' }[picked.match.how]) })
713
+ // 命中了就记一笔"上次用它"(写盘失败不抛,见 AssistantScenes.save)
714
+ if (assistantScenes.lastUsedFor(userId) !== picked.match.scene.id) assistantScenes.save()
715
+ } else if (picked.why) {
716
+ // **多义要留痕**:不然用户只看到"这次没带场景",不知道是因为撞车了
717
+ trace.steps.push({ kind: 'note', detail: picked.why })
718
+ }
650
719
  const messages: ApiMessage[] = [
651
- { role: 'system', content: this.buildSystemPrompt(userId, t) },
720
+ { role: 'system', content: this.buildSystemPrompt(userId, t, scene) },
652
721
  ...this.memory.workingWindow(userId).map(turn => ({ role: turn.role, content: turn.content })),
653
722
  ]
654
723
 
@@ -801,6 +870,12 @@ export class AssistantService {
801
870
  privacyGate.audit('MEMORY_LOAD_ISSUE', 0, this.memory.problem.slice(0, 80))
802
871
  onLog?.(`⚠ 记忆: ${this.memory.problem}`)
803
872
  }
873
+ // 场景文件同理:读不出来时用户面对的是"我加过的场景全没了",而原因在被留档的文件名里
874
+ if (assistantScenes.problem) {
875
+ appendLog(`[场景] ${assistantScenes.problem}`)
876
+ privacyGate.audit('SCENES_LOAD_ISSUE', 0, assistantScenes.problem.slice(0, 80))
877
+ onLog?.(`⚠ 场景: ${assistantScenes.problem}`)
878
+ }
804
879
  const { local } = this.engineConfig()
805
880
  // bot 那一段只在真有通道时报:没有通道还报一个 bot 账号,是让人以为它在收消息
806
881
  onLog?.('助手已启动 (' + (token ? `bot: ${configService.get('wechatOcAccountId')}, ` : '本机入口模式, ') +
@@ -880,6 +955,32 @@ export class AssistantService {
880
955
  return { used: this.dailyCount, limit: DAILY_LIMIT }
881
956
  }
882
957
 
958
+ /**
959
+ * 面板里那个"记忆"按钮要的:**它记住了什么**。只读,且**不经过模型**。
960
+ *
961
+ * 为什么不复用微信里那条 `记忆` 指令的文本:聊天那条路给的是"最近 5 条"的摘要(那是给对话用的),
962
+ * 面板要按时间列全、还要看得出每条是从哪句话来的,所以带上 `sourceQuote`。两边读的是同一份 memory,
963
+ * 只是呈现不同 —— 不各存一份。
964
+ *
965
+ * 桶(`userId`)由**调用方**决定:面板那条路传的是服务端自己那个 `memoryBucket`,不接受客户端指定。
966
+ */
967
+ memorySnapshot(userId: string): {
968
+ facts: { content: string; ts: number; sourceQuote: string }[]
969
+ summary: string
970
+ workingTurns: number
971
+ saveError: string
972
+ } {
973
+ return {
974
+ facts: this.memory.facts(userId).map(f => ({
975
+ content: f.content, ts: f.ts, sourceQuote: f.sourceQuote ?? '',
976
+ })),
977
+ summary: this.memory.summary(userId),
978
+ workingTurns: this.memory.workingWindow(userId).length,
979
+ // 落盘失败要传出去:用户问"你记住了什么"时,若上次根本没存上,那份答案就是假的
980
+ saveError: this.memory.lastSaveError ?? '',
981
+ }
982
+ }
983
+
883
984
  stop(): void {
884
985
  this.running = false
885
986
  this.svc?.stop()