weflow-cli 1.6.4 → 1.8.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 (205) hide show
  1. package/ARCHITECTURE.md +3 -2
  2. package/CHANGELOG.md +1966 -0
  3. package/OPERATIONS.md +808 -0
  4. package/README.en.md +93 -7
  5. package/README.md +31 -6
  6. package/bin/weflow-cli.ts +1245 -26
  7. package/dist/bin/weflow-cli.js +1349 -72
  8. package/dist/bin/weflow-cli.js.map +1 -1
  9. package/dist/mcp-server/index.js +39 -13
  10. package/dist/mcp-server/index.js.map +1 -1
  11. package/dist/src/core/appMsgFormat.d.ts +37 -0
  12. package/dist/src/core/appMsgFormat.d.ts.map +1 -0
  13. package/dist/src/core/appMsgFormat.js +106 -0
  14. package/dist/src/core/appMsgFormat.js.map +1 -0
  15. package/dist/src/core/ntCore.d.ts +43 -0
  16. package/dist/src/core/ntCore.d.ts.map +1 -1
  17. package/dist/src/core/ntCore.js +31 -0
  18. package/dist/src/core/ntCore.js.map +1 -1
  19. package/dist/src/core/sqlcipherCore.d.ts +0 -12
  20. package/dist/src/core/sqlcipherCore.d.ts.map +1 -1
  21. package/dist/src/core/sqlcipherCore.js +5 -64
  22. package/dist/src/core/sqlcipherCore.js.map +1 -1
  23. package/dist/src/core/wcdbCore.d.ts +1 -1
  24. package/dist/src/core/wcdbCore.d.ts.map +1 -1
  25. package/dist/src/core/wcdbCore.js +5 -1
  26. package/dist/src/core/wcdbCore.js.map +1 -1
  27. package/dist/src/panel/client.d.ts +25 -0
  28. package/dist/src/panel/client.d.ts.map +1 -0
  29. package/dist/src/panel/client.js +34 -0
  30. package/dist/src/panel/client.js.map +1 -0
  31. package/dist/src/panel/endpoint.d.ts +45 -0
  32. package/dist/src/panel/endpoint.d.ts.map +1 -0
  33. package/dist/src/panel/endpoint.js +132 -0
  34. package/dist/src/panel/endpoint.js.map +1 -0
  35. package/dist/src/panel/launch.d.ts +50 -0
  36. package/dist/src/panel/launch.d.ts.map +1 -0
  37. package/dist/src/panel/launch.js +74 -0
  38. package/dist/src/panel/launch.js.map +1 -0
  39. package/dist/src/panel/server.d.ts +23 -0
  40. package/dist/src/panel/server.d.ts.map +1 -0
  41. package/dist/src/panel/server.js +413 -0
  42. package/dist/src/panel/server.js.map +1 -0
  43. package/dist/src/panel/userId.d.ts +37 -0
  44. package/dist/src/panel/userId.d.ts.map +1 -0
  45. package/dist/src/panel/userId.js +56 -0
  46. package/dist/src/panel/userId.js.map +1 -0
  47. package/dist/src/services/assistantDaemon.d.ts +20 -3
  48. package/dist/src/services/assistantDaemon.d.ts.map +1 -1
  49. package/dist/src/services/assistantDaemon.js +46 -9
  50. package/dist/src/services/assistantDaemon.js.map +1 -1
  51. package/dist/src/services/assistantEval.d.ts +141 -0
  52. package/dist/src/services/assistantEval.d.ts.map +1 -0
  53. package/dist/src/services/assistantEval.js +497 -0
  54. package/dist/src/services/assistantEval.js.map +1 -0
  55. package/dist/src/services/assistantMemory.d.ts +106 -4
  56. package/dist/src/services/assistantMemory.d.ts.map +1 -1
  57. package/dist/src/services/assistantMemory.js +396 -61
  58. package/dist/src/services/assistantMemory.js.map +1 -1
  59. package/dist/src/services/assistantPrivacy.d.ts +15 -2
  60. package/dist/src/services/assistantPrivacy.d.ts.map +1 -1
  61. package/dist/src/services/assistantPrivacy.js +18 -4
  62. package/dist/src/services/assistantPrivacy.js.map +1 -1
  63. package/dist/src/services/assistantRouter.d.ts +41 -0
  64. package/dist/src/services/assistantRouter.d.ts.map +1 -0
  65. package/dist/src/services/assistantRouter.js +151 -0
  66. package/dist/src/services/assistantRouter.js.map +1 -0
  67. package/dist/src/services/assistantService.d.ts +209 -3
  68. package/dist/src/services/assistantService.d.ts.map +1 -1
  69. package/dist/src/services/assistantService.js +590 -75
  70. package/dist/src/services/assistantService.js.map +1 -1
  71. package/dist/src/services/assistantTools.d.ts +205 -1
  72. package/dist/src/services/assistantTools.d.ts.map +1 -1
  73. package/dist/src/services/assistantTools.js +1089 -58
  74. package/dist/src/services/assistantTools.js.map +1 -1
  75. package/dist/src/services/assistantTrace.d.ts +47 -0
  76. package/dist/src/services/assistantTrace.d.ts.map +1 -0
  77. package/dist/src/services/assistantTrace.js +150 -0
  78. package/dist/src/services/assistantTrace.js.map +1 -0
  79. package/dist/src/services/chatService.d.ts +13 -0
  80. package/dist/src/services/chatService.d.ts.map +1 -1
  81. package/dist/src/services/chatService.js +23 -0
  82. package/dist/src/services/chatService.js.map +1 -1
  83. package/dist/src/services/configService.d.ts +9 -0
  84. package/dist/src/services/configService.d.ts.map +1 -1
  85. package/dist/src/services/configService.js +15 -8
  86. package/dist/src/services/configService.js.map +1 -1
  87. package/dist/src/services/messageQuery.d.ts +35 -0
  88. package/dist/src/services/messageQuery.d.ts.map +1 -1
  89. package/dist/src/services/messageQuery.js +61 -0
  90. package/dist/src/services/messageQuery.js.map +1 -1
  91. package/dist/src/services/pythonBridge.d.ts +41 -0
  92. package/dist/src/services/pythonBridge.d.ts.map +1 -0
  93. package/dist/src/services/pythonBridge.js +119 -0
  94. package/dist/src/services/pythonBridge.js.map +1 -0
  95. package/dist/src/services/syncService.d.ts +127 -0
  96. package/dist/src/services/syncService.d.ts.map +1 -0
  97. package/dist/src/services/syncService.js +263 -0
  98. package/dist/src/services/syncService.js.map +1 -0
  99. package/dist/src/services/syncState.d.ts +113 -0
  100. package/dist/src/services/syncState.d.ts.map +1 -0
  101. package/dist/src/services/syncState.js +214 -0
  102. package/dist/src/services/syncState.js.map +1 -0
  103. package/dist/src/services/wechatMessageService.d.ts +25 -0
  104. package/dist/src/services/wechatMessageService.d.ts.map +1 -1
  105. package/dist/src/services/wechatMessageService.js +69 -7
  106. package/dist/src/services/wechatMessageService.js.map +1 -1
  107. package/dist/src/utils/atomicWrite.d.ts +4 -0
  108. package/dist/src/utils/atomicWrite.d.ts.map +1 -0
  109. package/dist/src/utils/atomicWrite.js +60 -0
  110. package/dist/src/utils/atomicWrite.js.map +1 -0
  111. package/dist/src/utils/dateRange.d.ts +15 -0
  112. package/dist/src/utils/dateRange.d.ts.map +1 -1
  113. package/dist/src/utils/dateRange.js +47 -0
  114. package/dist/src/utils/dateRange.js.map +1 -1
  115. package/dist/src/utils/text.d.ts +18 -0
  116. package/dist/src/utils/text.d.ts.map +1 -0
  117. package/dist/src/utils/text.js +20 -0
  118. package/dist/src/utils/text.js.map +1 -0
  119. package/docs/AI_INTERFACE.md +2 -0
  120. package/docs/DECISIONS.md +1484 -20
  121. package/docs/EXTENDING.md +187 -0
  122. package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +338 -0
  123. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -0
  124. package/docs/MCP.md +23 -4
  125. package/docs/PROJECT_STATE.md +299 -96
  126. package/docs/SETUP.md +22 -0
  127. package/docs/SYNC_CONTRACT.md +180 -0
  128. package/mcp-server/index.ts +681 -657
  129. package/package.json +84 -83
  130. package/resources/panel/ball-position.cjs +153 -0
  131. package/resources/panel/index.html +46 -0
  132. package/resources/panel/main.cjs +447 -0
  133. package/resources/panel/mascot-focus.png +0 -0
  134. package/resources/panel/mascot-happy.png +0 -0
  135. package/resources/panel/mascot-sorry.png +0 -0
  136. package/resources/panel/mascot-tired.png +0 -0
  137. package/resources/panel/mascot.png +0 -0
  138. package/resources/panel/package.json +7 -0
  139. package/resources/panel/panel.css +407 -0
  140. package/resources/panel/preload.cjs +72 -0
  141. package/resources/panel/quick-menu.cjs +197 -0
  142. package/resources/panel/renderer.js +441 -0
  143. package/resources/panel/tray-menu.cjs +40 -0
  144. package/resources/panel/tray.png +0 -0
  145. package/scripts/_utils.py +403 -54
  146. package/scripts/annual_report.py +4 -2
  147. package/scripts/article_notes.py +423 -0
  148. package/scripts/backfill_articles.py +438 -0
  149. package/scripts/biz_daily.py +590 -105
  150. package/scripts/chat_notes.py +470 -0
  151. package/scripts/classify_daily.py +10 -18
  152. package/scripts/compile_wiki.py +1059 -46
  153. package/scripts/create_reading_notes.py +199 -14
  154. package/scripts/decide.py +253 -0
  155. package/scripts/draft_reply.py +671 -0
  156. package/scripts/export_chat_html.py +212 -46
  157. package/scripts/extract_todos.py +31 -5
  158. package/scripts/fav_notes.py +318 -0
  159. package/scripts/fix_topics.py +2 -1
  160. package/scripts/generate_ai_report.py +214 -9
  161. package/scripts/generate_html.py +18 -3
  162. package/scripts/generate_review.py +2 -2
  163. package/scripts/jev_client.py +294 -0
  164. package/scripts/jev_probe.py +440 -0
  165. package/scripts/nt_common.py +82 -0
  166. package/scripts/nt_decrypt.py +1755 -1352
  167. package/scripts/pipeline.py +27 -2
  168. package/scripts/promote_ideas.py +19 -4
  169. package/scripts/quality_eval.py +707 -0
  170. package/scripts/rag_chat.py +4 -4
  171. package/scripts/read_image.py +187 -0
  172. package/scripts/reply_debt.py +488 -0
  173. package/scripts/route_cards.py +453 -0
  174. package/scripts/semantic_search.py +208 -49
  175. package/scripts/user_notes.py +315 -0
  176. package/scripts/vault_rag.py +13 -7
  177. package/scripts/vault_search.py +21 -4
  178. package/scripts/wiki_lint.py +397 -0
  179. package/src/core/appMsgFormat.ts +117 -0
  180. package/src/core/ntCore.ts +67 -0
  181. package/src/core/sqlcipherCore.ts +5 -61
  182. package/src/core/wcdbCore.ts +6 -2
  183. package/src/panel/client.ts +49 -0
  184. package/src/panel/endpoint.ts +144 -0
  185. package/src/panel/launch.ts +101 -0
  186. package/src/panel/server.ts +424 -0
  187. package/src/panel/userId.ts +72 -0
  188. package/src/services/assistantDaemon.ts +57 -9
  189. package/src/services/assistantEval.ts +611 -0
  190. package/src/services/assistantMemory.ts +413 -57
  191. package/src/services/assistantPrivacy.ts +18 -4
  192. package/src/services/assistantRouter.ts +191 -0
  193. package/src/services/assistantService.ts +671 -74
  194. package/src/services/assistantTools.ts +1118 -57
  195. package/src/services/assistantTrace.ts +175 -0
  196. package/src/services/chatService.ts +30 -0
  197. package/src/services/configService.ts +403 -385
  198. package/src/services/messageQuery.ts +97 -0
  199. package/src/services/pythonBridge.ts +139 -0
  200. package/src/services/syncService.ts +363 -0
  201. package/src/services/syncState.ts +275 -0
  202. package/src/services/wechatMessageService.ts +73 -7
  203. package/src/utils/atomicWrite.ts +56 -0
  204. package/src/utils/dateRange.ts +46 -0
  205. package/src/utils/text.ts +20 -0
@@ -0,0 +1,275 @@
1
+ /**
2
+ * 同步检查点与任务记录的本地状态。
3
+ *
4
+ * 两个位置, 生命周期不同 (见 docs/SYNC_CONTRACT.md):
5
+ * ~/.weflow-cli/sync/<source>-<name>-<hash>.json 跨运行的检查点, 每会话一份
6
+ * ~/.weflow-cli/jobs/<jobId>.json 单次运行的记录
7
+ *
8
+ * 按会话分文件而不是一个全局文件: 全局文件要求跨会话读改写并合并, 分文件则每次
9
+ * 写入都是整文件原子替换 (沿用 assistantMemory.ts 的 tmp + rename 做法)。
10
+ *
11
+ * 两条本模块特有的规矩:
12
+ * - 遇到不认识的 schema 一律拒绝覆盖并报错, 不猜测字段含义;
13
+ * - 写盘前统一过一遍脱敏, 保证密钥/绝对路径不会进状态文件 (D-001 / D-002 / D-025)。
14
+ */
15
+ import { createHash } from 'crypto'
16
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'fs'
17
+ import { join } from 'path'
18
+ import os from 'os'
19
+
20
+ export const SYNC_SCHEMA = 'weflow-sync/v1'
21
+ export const JOB_SCHEMA = 'weflow-job/v1'
22
+ export const SYNC_SOURCE = 'wechat-nt'
23
+ export const DEFAULT_OVERLAP_SECONDS = 300
24
+
25
+ /** 单次运行的结局。partial 是本项目新增的一档——部分完成不算成功。 */
26
+ export type JobState = 'planned' | 'waiting_confirmation' | 'running' | 'paused'
27
+ | 'completed' | 'partial' | 'failed' | 'cancelled'
28
+
29
+ /**
30
+ * 覆盖可信度。这是对「有没有读全」的正面回答, 不是内部细节。
31
+ *
32
+ * - complete 扫到的分片全部打开、打开的全读成功, 且没有更多
33
+ * - unverified 有分片打不开, 但打开的都读成功——已读部分可信, 未打开的分片内容无法判断
34
+ * - partial 已打开的分片读失败, 或还有更多, 或分页提前结束
35
+ */
36
+ export type SyncCoverage = 'complete' | 'unverified' | 'partial'
37
+
38
+ export interface SyncShardItem {
39
+ /** 仅 basename——绝不落绝对路径 */
40
+ name: string
41
+ opened: boolean
42
+ hasTalkerTable: boolean | null
43
+ rowsForTalker: number | null
44
+ /** null | KEY_REJECTED | OPEN_FAILED | READ_FAILED */
45
+ reason: string | null
46
+ }
47
+
48
+ export interface SyncState {
49
+ schema: string
50
+ schemaVersion: number
51
+ source: string
52
+ scope: string
53
+ talker: string
54
+ lastSuccessfulRun: string | null
55
+ lastAttempt: string | null
56
+ coveredFrom: string | null
57
+ coveredTo: string | null
58
+ checkpoint: { newestCreateTime: number | null; overlapSeconds: number }
59
+ recordsRead: number
60
+ recordsDeduplicated: number
61
+ recordsReturned: number
62
+ shardsRead: number
63
+ shardsFailed: number
64
+ shards: { scanned: number; opened: number; failed: number; items: SyncShardItem[] }
65
+ coverage: SyncCoverage
66
+ mayHaveMore: boolean
67
+ warnings: string[]
68
+ updatedAt: string | null
69
+ }
70
+
71
+ export interface JobRecord {
72
+ schema: string
73
+ jobId: string
74
+ kind: string
75
+ state: JobState
76
+ createdAt: string
77
+ updatedAt: string
78
+ window: { from: number | null; to: number | null } | null
79
+ progress: { completed: number; total: number } | null
80
+ resumeable: boolean
81
+ result: Record<string, unknown> | null
82
+ error: string | null
83
+ }
84
+
85
+ /** 未知 schema 时不覆盖原文件, 交由调用方决定如何处理。 */
86
+ export class UnknownSchemaError extends Error {
87
+ constructor(public readonly path: string, public readonly found: unknown) {
88
+ super(`不认识的 schema: ${String(found)} (${path})`)
89
+ this.name = 'UnknownSchemaError'
90
+ }
91
+ }
92
+
93
+ const CONFIG_DIR = join(os.homedir(), '.weflow-cli')
94
+ const SYNC_DIR = join(CONFIG_DIR, 'sync')
95
+ const JOBS_DIR = join(CONFIG_DIR, 'jobs')
96
+
97
+ const FORBIDDEN_KEYS = /^(ntKey|ntSalt|decryptKey|favKey|favPassphrase|contactKey|snsKey|wechatOcToken|apiKey|password|auth)$/i
98
+ /** 密钥形状的十六进制串 (>=32 hex)。 */
99
+ const HEX_KEY = /\b[0-9a-f]{32,}\b/gi
100
+ /** Windows 盘符、UNC、以及 POSIX 绝对路径。三类都要, 早期只处理了第一类。 */
101
+ const ABS_PATH = /(?:[A-Za-z]:[\\/][^\s"']*|\\\\[^\s"']+|\/(?:Users|home|root|var|tmp|opt|mnt)\/[^\s"']*)/g
102
+
103
+ /**
104
+ * 递归剔除不该落盘的字段。
105
+ *
106
+ * 状态文件是本机产物, 但一旦被日志、issue 或截图带出去就是泄露, 所以在写入
107
+ * 这一层兜底, 而不是指望每个调用方都记得。
108
+ */
109
+ export function sanitizeForState(value: unknown): unknown {
110
+ if (typeof value === 'string') {
111
+ return value.replace(HEX_KEY, '<redacted>').replace(ABS_PATH, '<path>')
112
+ }
113
+ if (Array.isArray(value)) return value.map(sanitizeForState)
114
+ if (value && typeof value === 'object') {
115
+ const out: Record<string, unknown> = {}
116
+ for (const [key, item] of Object.entries(value)) {
117
+ if (FORBIDDEN_KEYS.test(key)) continue
118
+ out[key] = sanitizeForState(item)
119
+ }
120
+ return out
121
+ }
122
+ return value
123
+ }
124
+
125
+ /**
126
+ * 文件名里只放一个可读前缀和一个 hash。
127
+ *
128
+ * hash 后缀让 `wxid_a@chatroom` 与 `wxid_a_chatroom` 落到不同文件, 而显示名
129
+ * 不会出现在目录列表里。
130
+ */
131
+ export function talkerSlug(talker: string): string {
132
+ const readable = talker.replace(/[^A-Za-z0-9._-]+/g, '_').slice(0, 60)
133
+ const digest = createHash('sha256').update(talker).digest('hex').slice(0, 8)
134
+ return `${readable}-${digest}`
135
+ }
136
+
137
+ function writeAtomic(path: string, payload: unknown): void {
138
+ if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true })
139
+ const tmp = `${path}.tmp`
140
+ writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8')
141
+ renameSync(tmp, path)
142
+ }
143
+
144
+ function readJson(path: string): unknown {
145
+ try {
146
+ return JSON.parse(readFileSync(path, 'utf8'))
147
+ } catch {
148
+ return null
149
+ }
150
+ }
151
+
152
+ export function emptySyncState(
153
+ talker: string,
154
+ scope = talker,
155
+ source = SYNC_SOURCE,
156
+ overlapSeconds = DEFAULT_OVERLAP_SECONDS,
157
+ ): SyncState {
158
+ return {
159
+ schema: SYNC_SCHEMA,
160
+ schemaVersion: 1,
161
+ source,
162
+ scope,
163
+ talker,
164
+ lastSuccessfulRun: null,
165
+ lastAttempt: null,
166
+ coveredFrom: null,
167
+ coveredTo: null,
168
+ checkpoint: { newestCreateTime: null, overlapSeconds },
169
+ recordsRead: 0,
170
+ recordsDeduplicated: 0,
171
+ recordsReturned: 0,
172
+ shardsRead: 0,
173
+ shardsFailed: 0,
174
+ shards: { scanned: 0, opened: 0, failed: 0, items: [] },
175
+ coverage: 'unverified',
176
+ mayHaveMore: false,
177
+ warnings: [],
178
+ updatedAt: null,
179
+ }
180
+ }
181
+
182
+ export class SyncStateStore {
183
+ constructor(
184
+ private readonly syncDir: string = SYNC_DIR,
185
+ private readonly jobsDir: string = JOBS_DIR,
186
+ ) {}
187
+
188
+ statePath(source: string, talker: string): string {
189
+ return join(this.syncDir, `${source}-${talkerSlug(talker)}.json`)
190
+ }
191
+
192
+ /** 读取检查点。无记录返回 null; schema 不认识则抛错, 不猜测。 */
193
+ read(source: string, talker: string): SyncState | null {
194
+ const path = this.statePath(source, talker)
195
+ const raw = readJson(path)
196
+ if (raw === null) return null
197
+ const state = raw as SyncState
198
+ if (state.schema !== SYNC_SCHEMA) throw new UnknownSchemaError(path, state.schema)
199
+ return state
200
+ }
201
+
202
+ write(state: SyncState): void {
203
+ const path = this.statePath(state.source, state.talker)
204
+ if (!existsSync(this.syncDir)) mkdirSync(this.syncDir, { recursive: true })
205
+ writeAtomic(path, sanitizeForState({ ...state, schema: SYNC_SCHEMA }))
206
+ }
207
+
208
+ /** 所有会话的检查点, 最近尝试在前。目录缺失或单个文件损坏都不影响其余。 */
209
+ list(): SyncState[] {
210
+ try {
211
+ if (!existsSync(this.syncDir)) return []
212
+ const states: SyncState[] = []
213
+ for (const name of readdirSync(this.syncDir)) {
214
+ if (!name.endsWith('.json')) continue
215
+ const raw = readJson(join(this.syncDir, name))
216
+ if (!raw || (raw as SyncState).schema !== SYNC_SCHEMA) continue
217
+ states.push(raw as SyncState)
218
+ }
219
+ return states.sort((a, b) => String(b.lastAttempt ?? '').localeCompare(String(a.lastAttempt ?? '')))
220
+ } catch {
221
+ return []
222
+ }
223
+ }
224
+
225
+ jobPath(jobId: string): string {
226
+ return join(this.jobsDir, `${jobId}.json`)
227
+ }
228
+
229
+ writeJob(job: JobRecord): void {
230
+ if (!existsSync(this.jobsDir)) mkdirSync(this.jobsDir, { recursive: true })
231
+ writeAtomic(this.jobPath(job.jobId), sanitizeForState({ ...job, schema: JOB_SCHEMA }))
232
+ }
233
+
234
+ readJob(jobId: string): JobRecord | null {
235
+ const path = this.jobPath(jobId)
236
+ const raw = readJson(path)
237
+ if (raw === null) return null
238
+ const job = raw as JobRecord
239
+ if (job.schema !== JOB_SCHEMA) throw new UnknownSchemaError(path, job.schema)
240
+ return job
241
+ }
242
+
243
+ /** 最近的 job 记录, 最新在前。 */
244
+ recentJobs(limit = 10): JobRecord[] {
245
+ try {
246
+ if (!existsSync(this.jobsDir)) return []
247
+ const ids = readdirSync(this.jobsDir)
248
+ .filter(name => name.endsWith('.json'))
249
+ .map(name => name.slice(0, -'.json'.length))
250
+ .sort()
251
+ .reverse()
252
+ .slice(0, limit)
253
+ const jobs: JobRecord[] = []
254
+ for (const id of ids) {
255
+ const job = this.readJob(id)
256
+ if (job) jobs.push(job)
257
+ }
258
+ return jobs
259
+ } catch {
260
+ return []
261
+ }
262
+ }
263
+ }
264
+
265
+ export const syncStateStore = new SyncStateStore()
266
+
267
+ /** jobId 形如 job_20260919_0001。同日按时间戳后四位递增, 保证可排序。 */
268
+ export function makeJobId(now = new Date()): string {
269
+ const stamp = [
270
+ now.getFullYear(),
271
+ String(now.getMonth() + 1).padStart(2, '0'),
272
+ String(now.getDate()).padStart(2, '0'),
273
+ ].join('')
274
+ return `job_${stamp}_${String(now.getTime() % 10000).padStart(4, '0')}`
275
+ }
@@ -18,6 +18,42 @@ function uuidHex(): string {
18
18
  return crypto.randomUUID().replace(/-/g, '')
19
19
  }
20
20
 
21
+ /**
22
+ * 长轮询失败的分类。
23
+ *
24
+ * **`-14` 才是"token 已失效"**——这是厂商实现里写死的(`third-party/WeKnora/internal/im/wechat/longpoll.go:10,151`
25
+ * `if result.ErrCode == -14 { return ErrTokenExpired }`),而本仓原来只查 `-1 || 401`。
26
+ * 查错码的后果**不是报错**,而是把"过期"当成普通错误:每 5 秒重试一次,永远重试下去,
27
+ * 而用户那边看不到任何异常(`channelActive` 只表示"配了 token",不表示轮询是活的)。
28
+ *
29
+ * 三分类的用意:
30
+ * - `ok`:正常(长轮询空响应会带 0,或干脆没有这两个字段——见下面的调用处注释);
31
+ * - `token-expired`:`-14` 或 HTTP 401。**重试不可能成功**,所以它的待遇与别的失败不同;
32
+ * - `retryable`:其余(网络抖动、`-1` 这类语义不明的码)。仍然重试,但**退避**,
33
+ * 免得把日志刷成噪音——噪音等于没说。
34
+ */
35
+ export function classifyPollResult(
36
+ data: { ret?: number | null; errcode?: number | null } | null | undefined,
37
+ httpStatus?: number,
38
+ ): 'ok' | 'token-expired' | 'retryable' {
39
+ if (httpStatus === 401) return 'token-expired'
40
+ const errcode = data?.errcode
41
+ if (errcode === -14) return 'token-expired'
42
+ const ret = data?.ret
43
+ const badRet = ret != null && ret !== 0
44
+ const badCode = errcode != null && errcode !== 0
45
+ if (badRet || badCode) return 'retryable'
46
+ return 'ok'
47
+ }
48
+
49
+ /** 重试间隔:1s 起、翻倍、封顶 30s(厂商那边是 1s→30s 指数退避,同一条路数) */
50
+ export const POLL_BACKOFF_MS = [1000, 2000, 4000, 8000, 16000, 30000]
51
+
52
+ export function nextPollDelay(failures: number): number {
53
+ const index = Math.min(Math.max(failures - 1, 0), POLL_BACKOFF_MS.length - 1)
54
+ return POLL_BACKOFF_MS[index]
55
+ }
56
+
21
57
  export class WechatMessageService {
22
58
  private client: WechatClient
23
59
  private config: WechatOCConfig
@@ -26,6 +62,8 @@ export class WechatMessageService {
26
62
  private contextTokens: Map<string, string> = new Map()
27
63
  private syncBuf: string
28
64
  private messageCallbacks: Array<(msg: WechatInboundMessage) => void> = []
65
+ /** 服务端说过 token 失效了。**只由长轮询置位**(见 `classifyPollResult`),恢复时清掉 */
66
+ private tokenExpired = false
29
67
 
30
68
  constructor(config: WechatOCConfig = {}) {
31
69
  this.config = config
@@ -140,6 +178,8 @@ export class WechatMessageService {
140
178
  // ====== Message Polling ======
141
179
 
142
180
  async startPolling(): Promise<void> {
181
+ let failures = 0
182
+ let expiryReported = false
143
183
  while (!this.shutdownFlag) {
144
184
  try {
145
185
  const data = await this.client.requestJson('POST', 'ilink/bot/getupdates', {
@@ -152,15 +192,31 @@ export class WechatMessageService {
152
192
  })
153
193
 
154
194
  // ret/errcode may be absent on empty response (normal for long-poll timeout)
155
- if (data.ret != null && data.ret !== 0 || data.errcode != null && data.errcode !== 0) {
156
- console.error(`getupdates error: ret=${data.ret} errcode=${data.errcode} errmsg=${data.errmsg || 'unknown'}`)
157
- if (data.errcode === -1 || data.errcode === 401) {
158
- console.error('Token may have expired, please login again')
195
+ const kind = classifyPollResult(data)
196
+ if (kind !== 'ok') {
197
+ failures += 1
198
+ const detail = `ret=${data.ret} errcode=${data.errcode} errmsg=${data.errmsg || 'unknown'}`
199
+ if (kind === 'token-expired') {
200
+ this.tokenExpired = true
201
+ // **只在状态翻过去时喊一次**:每 5 秒刷一行会被当成噪音,而噪音等于没说
202
+ if (!expiryReported) {
203
+ expiryReported = true
204
+ console.error(`微信通道的 token 已失效(${detail})——重试不会成功。`
205
+ + '重新登录:weflow-cli login-wechat,之后重启助手(weflow-cli assistant stop/start)'
206
+ + ';在此之前本机面板那条入口照常可用。')
207
+ }
208
+ } else {
209
+ console.error(`getupdates error: ${detail}`)
159
210
  }
160
- await sleep(5000)
211
+ await sleep(nextPollDelay(failures))
161
212
  continue
162
213
  }
163
214
 
215
+ // 这一轮是通的:清掉失败计数与"说过一次"的标记(恢复了就该重新能喊)
216
+ failures = 0
217
+ expiryReported = false
218
+ this.tokenExpired = false
219
+
164
220
  if (data.get_updates_buf) {
165
221
  this.syncBuf = data.get_updates_buf
166
222
  configService.set('wechatOcSyncBuf', this.syncBuf as any)
@@ -172,7 +228,11 @@ export class WechatMessageService {
172
228
  const inbound = this.parseInboundMessage(msg)
173
229
  if (inbound) {
174
230
  for (const cb of this.messageCallbacks) {
175
- try { cb(inbound) } catch {}
231
+ try { cb(inbound) } catch (error: any) {
232
+ // **不许吞**:这里进的是助手处理消息的入口。吞掉的话,用户发了消息、
233
+ // 助手什么都没发生、日志里一个字都没有——三种证据全无,最难查的一种坏法。
234
+ console.error(`消息回调异常(这条消息没有被处理):${error?.message || error}`)
235
+ }
176
236
  }
177
237
  }
178
238
  }
@@ -183,12 +243,18 @@ export class WechatMessageService {
183
243
  // Restart poll immediately
184
244
  continue
185
245
  }
246
+ failures += 1
186
247
  console.error(`Polling error: ${e.message}`)
187
- await sleep(5000)
248
+ await sleep(nextPollDelay(failures))
188
249
  }
189
250
  }
190
251
  }
191
252
 
253
+ /** token 是否已被服务端判为失效(面板/状态用它来说实话,见 `isChannelActive`) */
254
+ isTokenExpired(): boolean {
255
+ return this.tokenExpired
256
+ }
257
+
192
258
  async stop(): Promise<void> {
193
259
  this.shutdownFlag = true
194
260
  }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * 原子写:先写 `.tmp`,再 `rename` 覆盖目标。
3
+ *
4
+ * ## 为什么不能直接 `renameSync`
5
+ *
6
+ * **Windows 上 rename 覆盖一个正被别的句柄打开的文件会失败(EPERM)**——只读打开就够触发。
7
+ * 实测:另一个句柄 `openSync(target, 'r')` 拿着时 `renameSync` 报 EPERM,句柄一关就成功。
8
+ * 杀毒、搜索索引器、另一个进程的读者都会短暂造成这个状态。
9
+ *
10
+ * 后果是**静默丢写入**:调用方(`AssistantMemory.save()`、`configService.save()`)都是
11
+ * catch 掉、只记一个原因,于是"保存成功"与"保存没发生"在界面上看不出区别。
12
+ * 这不是理论风险——本仓库一次全量测试里,记忆文件那次保存之后就真的没写进去
13
+ * (`save()` 之后文件里没有 `version`,而那条用例本身是绿的)。
14
+ *
15
+ * ## 所以
16
+ *
17
+ * 忙就重试几次(同步 sleep:调用方是同步的 `save()`,不值得为它把整条链改成异步),
18
+ * 仍然忙就**直接覆盖写目标**——保住这份数据,比保住"原子"这个性质更重要。
19
+ *
20
+ * 落盘的字节与原子写一致(都是同一份字符串),所以读者不会看到半截内容;
21
+ * 退化的那一次只失去"改名是瞬时"这一点。留给同机的读者一个真实边界:
22
+ * 若那个句柄是以拒绝写入的方式打开的,直接写也会失败,这时照旧抛给调用方。
23
+ */
24
+ import { writeFileSync, renameSync, rmSync } from 'node:fs'
25
+
26
+ /** 忙等。`Atomics.wait` 是标准库里的同步睡眠,不用引第三方包 */
27
+ function sleepSync(ms: number): void {
28
+ try {
29
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
30
+ } catch { /* 不支持就干脆不等,直接重试 */ }
31
+ }
32
+
33
+ /** 这些是"文件正忙"的形态,值得重试;别的错误(权限、路径不存在)立刻抛 */
34
+ const BUSY_CODES = new Set(['EPERM', 'EACCES', 'EBUSY'])
35
+
36
+ export function writeFileAtomic(target: string, data: string, options: { mode?: number } = {}): void {
37
+ const tmp = `${target}.tmp`
38
+ writeFileSync(tmp, data, { encoding: 'utf8', mode: options.mode })
39
+
40
+ for (let attempt = 0; attempt < 5; attempt++) {
41
+ try {
42
+ renameSync(tmp, target)
43
+ return
44
+ } catch (error: any) {
45
+ if (!BUSY_CODES.has(error?.code)) {
46
+ try { rmSync(tmp, { force: true }) } catch { /* 清理失败不掩盖原错误 */ }
47
+ throw error
48
+ }
49
+ sleepSync(10 * (attempt + 1))
50
+ }
51
+ }
52
+
53
+ // 还在忙:直接覆盖。数据不丢,只是不再"原子"
54
+ writeFileSync(target, data, { encoding: 'utf8', mode: options.mode })
55
+ try { rmSync(tmp, { force: true }) } catch { /* 见上 */ }
56
+ }
@@ -56,3 +56,49 @@ export function resolveExportDateRange(options: {
56
56
  }
57
57
  return { from, to }
58
58
  }
59
+
60
+ /**
61
+ * 助手工具用的时间表达式:`2026-09-16`(本地零点)、`3d` / `2w` / `12h`、以及裸数字(= 天数)。
62
+ * 回**秒**——与消息里的 `createTime` 同一单位。
63
+ *
64
+ * 为什么允许相对写法:模型没法可靠地把"上周三"算成日期(系统提示里给了当前时间也只是够用),
65
+ * 而"过去三天"这种说法只要一个 `3d` 就表达了,不用它自己数日子。
66
+ *
67
+ * 日/周**按当天零点**而不是精确 24 小时前:人说的"三天"指的是那三天,不是一个瞬时。
68
+ * 给不出明确的窗口就抛——**猜一个比报错更糟**,模型会以为它查的正是自己说的那段时间。
69
+ */
70
+ export function resolveSince(value: unknown, nowMs = Date.now()): number | undefined {
71
+ const text = String(value ?? '').trim()
72
+ if (!text) return undefined
73
+ const relative = text.match(/^(\d+)\s*([dhw])$/i)
74
+ if (relative) {
75
+ const amount = Number(relative[1])
76
+ const unit = relative[2].toLowerCase()
77
+ if (unit === 'h') return Math.floor((nowMs - amount * 3_600_000) / 1000)
78
+ return startOfDayMs(nowMs - amount * (unit === 'w' ? 7 : 1) * 86_400_000)
79
+ }
80
+ if (/^\d+$/.test(text)) return startOfDayMs(nowMs - Number(text) * 86_400_000)
81
+ return parseLocalDateOrIso(text)
82
+ }
83
+
84
+ /** 上界:`2026-09-16` 指**含当天**(当天 23:59:59),因为"到那天为止"总是指一整天。 */
85
+ export function resolveUntil(value: unknown): number | undefined {
86
+ const text = String(value ?? '').trim()
87
+ if (!text) return undefined
88
+ if (/^\d{4}-\d{2}-\d{2}$/.test(text)) return parseLocalDateOrIso(text, true)
89
+ return parseLocalDateOrIso(text)
90
+ }
91
+
92
+ function startOfDayMs(ms: number): number {
93
+ const date = new Date(ms)
94
+ date.setHours(0, 0, 0, 0)
95
+ return Math.floor(date.getTime() / 1000)
96
+ }
97
+
98
+ /** 给系统提示/工具输出用的一行当前时间。测试要能固定它,所以 `now` 可注入。 */
99
+ export function nowLine(now = new Date()): string {
100
+ const pad = (n: number) => String(n).padStart(2, '0')
101
+ const week = ['日', '一', '二', '三', '四', '五', '六'][now.getDay()]
102
+ return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}(星期${week})`
103
+ + `${pad(now.getHours())}:${pad(now.getMinutes())}`
104
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * 显示形态里的小工具。
3
+ *
4
+ * 只放跨模块共用的:`clipWithMarker` 有两处需要——AppMsg 的显示形态
5
+ * (`core/appMsgFormat.ts`,引用消息的正文与被引原文各截一次)和助手读聊天记录时
6
+ * 每条消息的字数上限(`services/assistantTools.ts`)。
7
+ *
8
+ * Python 侧有一份同义的实现(`nt_decrypt.py` 的 `_clip`),做的是同一件事。
9
+ */
10
+
11
+ /**
12
+ * 超长截断并**留下省略号**。
13
+ *
14
+ * 不标记的截断读起来像一句说完的话:被引原文最长的 12733 字(引用了一整篇公众号文章),
15
+ * 切完看着像"这就是全部";助手把一条 300 字的聊天记录切到 160,模型会以为对方就说完了
16
+ * 这半句。标记只花一个字符,换来的是"这里被切过"这个事实没有丢。
17
+ */
18
+ export function clipWithMarker(text: string, limit: number): string {
19
+ return text.length <= limit ? text : text.slice(0, limit) + '…'
20
+ }