weflow-cli 1.7.0 → 1.8.1
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.
- package/ARCHITECTURE.md +3 -2
- package/CHANGELOG.md +2108 -2
- package/OPERATIONS.md +810 -1
- package/README.en.md +96 -10
- package/README.md +34 -9
- package/bin/weflow-cli.ts +937 -28
- package/dist/bin/weflow-cli.js +955 -28
- package/dist/bin/weflow-cli.js.map +1 -1
- package/dist/mcp-server/index.js +39 -13
- package/dist/mcp-server/index.js.map +1 -1
- package/dist/src/core/appMsgFormat.d.ts +37 -0
- package/dist/src/core/appMsgFormat.d.ts.map +1 -0
- package/dist/src/core/appMsgFormat.js +106 -0
- package/dist/src/core/appMsgFormat.js.map +1 -0
- package/dist/src/core/ntCore.d.ts +19 -2
- package/dist/src/core/ntCore.d.ts.map +1 -1
- package/dist/src/core/ntCore.js +11 -1
- package/dist/src/core/ntCore.js.map +1 -1
- package/dist/src/core/sqlcipherCore.d.ts +0 -12
- package/dist/src/core/sqlcipherCore.d.ts.map +1 -1
- package/dist/src/core/sqlcipherCore.js +5 -64
- package/dist/src/core/sqlcipherCore.js.map +1 -1
- package/dist/src/core/wcdbCore.d.ts +1 -1
- package/dist/src/core/wcdbCore.d.ts.map +1 -1
- package/dist/src/core/wcdbCore.js +5 -1
- package/dist/src/core/wcdbCore.js.map +1 -1
- package/dist/src/panel/client.d.ts +25 -0
- package/dist/src/panel/client.d.ts.map +1 -0
- package/dist/src/panel/client.js +34 -0
- package/dist/src/panel/client.js.map +1 -0
- package/dist/src/panel/endpoint.d.ts +45 -0
- package/dist/src/panel/endpoint.d.ts.map +1 -0
- package/dist/src/panel/endpoint.js +132 -0
- package/dist/src/panel/endpoint.js.map +1 -0
- package/dist/src/panel/launch.d.ts +50 -0
- package/dist/src/panel/launch.d.ts.map +1 -0
- package/dist/src/panel/launch.js +74 -0
- package/dist/src/panel/launch.js.map +1 -0
- package/dist/src/panel/server.d.ts +23 -0
- package/dist/src/panel/server.d.ts.map +1 -0
- package/dist/src/panel/server.js +413 -0
- package/dist/src/panel/server.js.map +1 -0
- package/dist/src/panel/userId.d.ts +37 -0
- package/dist/src/panel/userId.d.ts.map +1 -0
- package/dist/src/panel/userId.js +56 -0
- package/dist/src/panel/userId.js.map +1 -0
- package/dist/src/services/assistantDaemon.d.ts +20 -3
- package/dist/src/services/assistantDaemon.d.ts.map +1 -1
- package/dist/src/services/assistantDaemon.js +46 -9
- package/dist/src/services/assistantDaemon.js.map +1 -1
- package/dist/src/services/assistantEval.d.ts +141 -0
- package/dist/src/services/assistantEval.d.ts.map +1 -0
- package/dist/src/services/assistantEval.js +497 -0
- package/dist/src/services/assistantEval.js.map +1 -0
- package/dist/src/services/assistantMemory.d.ts +106 -4
- package/dist/src/services/assistantMemory.d.ts.map +1 -1
- package/dist/src/services/assistantMemory.js +396 -61
- package/dist/src/services/assistantMemory.js.map +1 -1
- package/dist/src/services/assistantPrivacy.d.ts +15 -2
- package/dist/src/services/assistantPrivacy.d.ts.map +1 -1
- package/dist/src/services/assistantPrivacy.js +25 -6
- package/dist/src/services/assistantPrivacy.js.map +1 -1
- package/dist/src/services/assistantRouter.d.ts +41 -0
- package/dist/src/services/assistantRouter.d.ts.map +1 -0
- package/dist/src/services/assistantRouter.js +151 -0
- package/dist/src/services/assistantRouter.js.map +1 -0
- package/dist/src/services/assistantService.d.ts +209 -3
- package/dist/src/services/assistantService.d.ts.map +1 -1
- package/dist/src/services/assistantService.js +599 -75
- package/dist/src/services/assistantService.js.map +1 -1
- package/dist/src/services/assistantTools.d.ts +217 -1
- package/dist/src/services/assistantTools.d.ts.map +1 -1
- package/dist/src/services/assistantTools.js +1116 -59
- package/dist/src/services/assistantTools.js.map +1 -1
- package/dist/src/services/assistantTrace.d.ts +47 -0
- package/dist/src/services/assistantTrace.d.ts.map +1 -0
- package/dist/src/services/assistantTrace.js +150 -0
- package/dist/src/services/assistantTrace.js.map +1 -0
- package/dist/src/services/chatService.d.ts +1 -1
- package/dist/src/services/chatService.d.ts.map +1 -1
- package/dist/src/services/chatService.js +5 -2
- package/dist/src/services/chatService.js.map +1 -1
- package/dist/src/services/configService.d.ts +9 -0
- package/dist/src/services/configService.d.ts.map +1 -1
- package/dist/src/services/configService.js +15 -8
- package/dist/src/services/configService.js.map +1 -1
- package/dist/src/services/pythonBridge.d.ts +41 -0
- package/dist/src/services/pythonBridge.d.ts.map +1 -0
- package/dist/src/services/pythonBridge.js +119 -0
- package/dist/src/services/pythonBridge.js.map +1 -0
- package/dist/src/services/syncService.d.ts +8 -2
- package/dist/src/services/syncService.d.ts.map +1 -1
- package/dist/src/services/syncService.js +26 -7
- package/dist/src/services/syncService.js.map +1 -1
- package/dist/src/services/wechatMessageService.d.ts +25 -0
- package/dist/src/services/wechatMessageService.d.ts.map +1 -1
- package/dist/src/services/wechatMessageService.js +69 -7
- package/dist/src/services/wechatMessageService.js.map +1 -1
- package/dist/src/services/wereadService.d.ts +33 -6
- package/dist/src/services/wereadService.d.ts.map +1 -1
- package/dist/src/services/wereadService.js +27 -2
- package/dist/src/services/wereadService.js.map +1 -1
- package/dist/src/utils/atomicWrite.d.ts +4 -0
- package/dist/src/utils/atomicWrite.d.ts.map +1 -0
- package/dist/src/utils/atomicWrite.js +60 -0
- package/dist/src/utils/atomicWrite.js.map +1 -0
- package/dist/src/utils/dateRange.d.ts +15 -0
- package/dist/src/utils/dateRange.d.ts.map +1 -1
- package/dist/src/utils/dateRange.js +47 -0
- package/dist/src/utils/dateRange.js.map +1 -1
- package/dist/src/utils/text.d.ts +18 -0
- package/dist/src/utils/text.d.ts.map +1 -0
- package/dist/src/utils/text.js +20 -0
- package/dist/src/utils/text.js.map +1 -0
- package/docs/AI_INTERFACE.md +2 -0
- package/docs/BRANCHES.md +13 -4
- package/docs/DECISIONS.md +1424 -0
- package/docs/EXTENDING.md +187 -0
- package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -0
- package/docs/MCP.md +23 -4
- package/docs/PROJECT_STATE.md +210 -9
- package/docs/SETUP.md +22 -0
- package/docs/SYNC_CONTRACT.md +31 -3
- package/mcp-server/index.ts +681 -657
- package/package.json +84 -83
- package/resources/panel/ball-position.cjs +153 -0
- package/resources/panel/index.html +46 -0
- package/resources/panel/main.cjs +483 -0
- package/resources/panel/mascot-happy.png +0 -0
- package/resources/panel/mascot-sorry.png +0 -0
- package/resources/panel/mascot-thinking.png +0 -0
- package/resources/panel/mascot-tired.png +0 -0
- package/resources/panel/mascot.png +0 -0
- package/resources/panel/package.json +7 -0
- package/resources/panel/panel.css +415 -0
- package/resources/panel/preload.cjs +79 -0
- package/resources/panel/quick-menu.cjs +197 -0
- package/resources/panel/renderer.js +470 -0
- package/resources/panel/tray-menu.cjs +40 -0
- package/resources/panel/tray.png +0 -0
- package/scripts/_utils.py +425 -54
- package/scripts/annual_report.py +4 -2
- package/scripts/article_notes.py +423 -0
- package/scripts/backfill_articles.py +438 -0
- package/scripts/biz_daily.py +590 -105
- package/scripts/chat_notes.py +470 -0
- package/scripts/classify_daily.py +10 -18
- package/scripts/compile_wiki.py +1060 -46
- package/scripts/create_reading_notes.py +199 -14
- package/scripts/decide.py +253 -0
- package/scripts/draft_reply.py +671 -0
- package/scripts/export_chat_html.py +35 -33
- package/scripts/extract_todos.py +31 -5
- package/scripts/fav_notes.py +318 -0
- package/scripts/fix_topics.py +2 -1
- package/scripts/generate_ai_report.py +214 -9
- package/scripts/generate_html.py +18 -3
- package/scripts/generate_review.py +2 -2
- package/scripts/jev_client.py +294 -0
- package/scripts/jev_probe.py +440 -0
- package/scripts/nt_common.py +82 -0
- package/scripts/nt_decrypt.py +1755 -1460
- package/scripts/pipeline.py +27 -2
- package/scripts/promote_ideas.py +19 -4
- package/scripts/quality_eval.py +707 -0
- package/scripts/rag_chat.py +4 -4
- package/scripts/read_image.py +187 -0
- package/scripts/reply_debt.py +488 -0
- package/scripts/route_cards.py +453 -0
- package/scripts/semantic_search.py +208 -49
- package/scripts/user_notes.py +315 -0
- package/scripts/vault_rag.py +13 -7
- package/scripts/vault_search.py +21 -4
- package/scripts/wiki_lint.py +429 -0
- package/src/core/appMsgFormat.ts +117 -0
- package/src/core/ntCore.ts +27 -2
- package/src/core/sqlcipherCore.ts +5 -61
- package/src/core/wcdbCore.ts +6 -2
- package/src/panel/client.ts +49 -0
- package/src/panel/endpoint.ts +144 -0
- package/src/panel/launch.ts +101 -0
- package/src/panel/server.ts +424 -0
- package/src/panel/userId.ts +72 -0
- package/src/services/assistantDaemon.ts +57 -9
- package/src/services/assistantEval.ts +611 -0
- package/src/services/assistantMemory.ts +413 -57
- package/src/services/assistantPrivacy.ts +25 -6
- package/src/services/assistantRouter.ts +191 -0
- package/src/services/assistantService.ts +681 -74
- package/src/services/assistantTools.ts +1149 -58
- package/src/services/assistantTrace.ts +175 -0
- package/src/services/chatService.ts +6 -2
- package/src/services/configService.ts +403 -385
- package/src/services/pythonBridge.ts +139 -0
- package/src/services/syncService.ts +37 -9
- package/src/services/wechatMessageService.ts +73 -7
- package/src/services/wereadService.ts +43 -7
- package/src/utils/atomicWrite.ts +56 -0
- package/src/utils/dateRange.ts +46 -0
- package/src/utils/text.ts +20 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 调本仓 Python 脚本的**唯一入口**:拿 JSON 回来,或者把失败说清楚。
|
|
3
|
+
*
|
|
4
|
+
* 为什么要有这一层:此前有两份实现——`get_todos` 用 `execFile`(参数走 argv),路由用
|
|
5
|
+
* `spawn`(请求走 stdin)。两份各有一套超时、错误、stdout 解析的写法,正是"同一件事两处
|
|
6
|
+
* 实现"的老毛病;而工具只会越加越多。
|
|
7
|
+
*
|
|
8
|
+
* 三条约定:
|
|
9
|
+
* 1. **只有 JSON 出口**:脚本要么给出可解析的 JSON,要么给出错误——不解析人类可读文本
|
|
10
|
+
* (那种解析在脚本改一行格式时就悄悄失效)。
|
|
11
|
+
* 2. **JSON 可以在最后一行**:有些脚本(如 route_cards)前面会打印给人看的进度行。
|
|
12
|
+
* 所以先试整份 stdout,再退一步取"最后一行能解析成 JSON 的"。
|
|
13
|
+
* 3. **失败要说清是什么失败**:退出码、还是没有 JSON、还是超时——三者分开报,别都变成
|
|
14
|
+
* "执行失败"。
|
|
15
|
+
*/
|
|
16
|
+
import { spawn } from 'node:child_process'
|
|
17
|
+
import { join } from 'node:path'
|
|
18
|
+
import { resolvePackageRoot } from '../utils/packageRoot.js'
|
|
19
|
+
import { createPythonProcessEnv } from '../utils/pythonProcessEnv.js'
|
|
20
|
+
import { getPythonCommand } from '../utils/python.js'
|
|
21
|
+
|
|
22
|
+
export interface ScriptRun {
|
|
23
|
+
stdout: string
|
|
24
|
+
stderr: string
|
|
25
|
+
/** 进程退出码;被信号杀死时为 null */
|
|
26
|
+
code: number | null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 注入点:测试用假 runner 驱动,不 spawn 真进程 */
|
|
30
|
+
export type ScriptRunner = (scriptPath: string, args: string[],
|
|
31
|
+
options: { timeoutMs: number; stdin?: string; env?: Record<string, string> }) => Promise<ScriptRun>
|
|
32
|
+
|
|
33
|
+
export interface JsonResult<T> {
|
|
34
|
+
ok: boolean
|
|
35
|
+
data?: T
|
|
36
|
+
/** 失败原因(已分类:超时 / 退出码 / 没有 JSON) */
|
|
37
|
+
error?: string
|
|
38
|
+
/** 失败时的 stderr 尾巴,便于定位 */
|
|
39
|
+
stderr?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const DEFAULT_TIMEOUT_MS = 30_000
|
|
43
|
+
|
|
44
|
+
function realRunner(scriptPath: string, args: string[],
|
|
45
|
+
options: { timeoutMs: number; stdin?: string; env?: Record<string, string> }): Promise<ScriptRun> {
|
|
46
|
+
return new Promise((resolve, reject) => {
|
|
47
|
+
const child = spawn(getPythonCommand(), [scriptPath, ...args], {
|
|
48
|
+
// 用户输入走**环境变量**而不是 argv:这是仓库写进测试的隐私纪律(进程列表里看不到正文)
|
|
49
|
+
env: createPythonProcessEnv(options.env ?? {}),
|
|
50
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
51
|
+
})
|
|
52
|
+
let stdout = ''
|
|
53
|
+
let stderr = ''
|
|
54
|
+
let settled = false
|
|
55
|
+
const timer = setTimeout(() => {
|
|
56
|
+
if (settled) return
|
|
57
|
+
settled = true
|
|
58
|
+
child.kill()
|
|
59
|
+
reject(new Error(`脚本超时(${options.timeoutMs}ms)`))
|
|
60
|
+
}, options.timeoutMs)
|
|
61
|
+
|
|
62
|
+
child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString() })
|
|
63
|
+
child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString() })
|
|
64
|
+
child.on('error', (error) => {
|
|
65
|
+
if (settled) return
|
|
66
|
+
settled = true
|
|
67
|
+
clearTimeout(timer)
|
|
68
|
+
reject(error)
|
|
69
|
+
})
|
|
70
|
+
child.on('close', (code) => {
|
|
71
|
+
if (settled) return
|
|
72
|
+
settled = true
|
|
73
|
+
clearTimeout(timer)
|
|
74
|
+
resolve({ stdout, stderr, code })
|
|
75
|
+
})
|
|
76
|
+
if (options.stdin !== undefined) child.stdin.end(options.stdin)
|
|
77
|
+
else child.stdin.end()
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let runner: ScriptRunner = realRunner
|
|
82
|
+
|
|
83
|
+
/** 换掉 runner(测试用)。传 null 恢复真实实现。 */
|
|
84
|
+
export function setScriptRunner(next: ScriptRunner | null): void {
|
|
85
|
+
runner = next ?? realRunner
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 脚本路径:一律相对包根下的 `scripts/`,不给调用方自己拼路径的自由 */
|
|
89
|
+
export function scriptPath(name: string): string {
|
|
90
|
+
return join(resolvePackageRoot(import.meta.url), 'scripts', name)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 从 stdout 里取 JSON:先试整份,再取最后一行能解析的 */
|
|
94
|
+
export function parseJsonFrom(stdout: string): { ok: boolean; data?: any } {
|
|
95
|
+
const whole = stdout.trim()
|
|
96
|
+
if (whole) {
|
|
97
|
+
try { return { ok: true, data: JSON.parse(whole) } } catch { /* 落下去取最后一行 */ }
|
|
98
|
+
}
|
|
99
|
+
const lines = stdout.split(/\r?\n/).map(l => l.trim()).filter(Boolean)
|
|
100
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
101
|
+
if (!lines[i].startsWith('{') && !lines[i].startsWith('[')) continue
|
|
102
|
+
try { return { ok: true, data: JSON.parse(lines[i]) } } catch { /* 继续往前找 */ }
|
|
103
|
+
}
|
|
104
|
+
return { ok: false }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 跑一个脚本并解析它给的 JSON。
|
|
109
|
+
*
|
|
110
|
+
* **不抛异常**:调用方(工具分支)要的是"这次调用成不成功",而不是一层 try/catch。
|
|
111
|
+
* 失败的三种情形分开报——超时、退出码非 0、退出码 0 但没有 JSON。
|
|
112
|
+
*/
|
|
113
|
+
export async function runPythonJson<T = any>(
|
|
114
|
+
script: string, args: string[],
|
|
115
|
+
options: { timeoutMs?: number; stdin?: string; env?: Record<string, string> } = {},
|
|
116
|
+
): Promise<JsonResult<T>> {
|
|
117
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
118
|
+
let run: ScriptRun
|
|
119
|
+
try {
|
|
120
|
+
run = await runner(scriptPath(script), args, { timeoutMs, stdin: options.stdin, env: options.env })
|
|
121
|
+
} catch (error: any) {
|
|
122
|
+
return { ok: false, error: String(error?.message ?? error).slice(0, 200) }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const parsed = parseJsonFrom(run.stdout)
|
|
126
|
+
const stderrTail = run.stderr.trim().split(/\r?\n/).filter(Boolean).slice(-3).join(' | ').slice(0, 300)
|
|
127
|
+
|
|
128
|
+
if (run.code !== 0) {
|
|
129
|
+
return { ok: false, error: `脚本退出码 ${run.code}`, stderr: stderrTail }
|
|
130
|
+
}
|
|
131
|
+
if (!parsed.ok) {
|
|
132
|
+
return { ok: false, error: '脚本没有给出可解析的 JSON', stderr: stderrTail }
|
|
133
|
+
}
|
|
134
|
+
const data: any = parsed.data
|
|
135
|
+
if (data && typeof data === 'object' && 'success' in data && data.success === false) {
|
|
136
|
+
return { ok: false, error: String(data.error ?? '脚本报错').slice(0, 200), stderr: stderrTail }
|
|
137
|
+
}
|
|
138
|
+
return { ok: true, data: data as T }
|
|
139
|
+
}
|
|
@@ -116,9 +116,16 @@ export function deriveCoverage(input: {
|
|
|
116
116
|
mayHaveMore: boolean
|
|
117
117
|
}): SyncCoverage {
|
|
118
118
|
if (input.mayHaveMore) return 'partial'
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
119
|
+
// Any per-shard reason means those rows were not read: a fault after opening
|
|
120
|
+
// (READ_FAILED), a table the reader can name nothing in (SCHEMA_MISMATCH), or
|
|
121
|
+
// a window the shard cannot express (WINDOW_UNAVAILABLE). Coverage is about
|
|
122
|
+
// whether the range was read, so all of them make it partial. A shard read
|
|
123
|
+
// with columns missing is *not* one of these - its rows did come back.
|
|
124
|
+
// A shard that never opened is a different answer and stays 'unverified'
|
|
125
|
+
// below: nothing is known about its rows either way, which is not the same
|
|
126
|
+
// claim as knowing they were missed.
|
|
127
|
+
const anyShardUnread = input.shards?.items.some(item => item.opened && item.reason)
|
|
128
|
+
if (anyShardUnread) return 'partial'
|
|
122
129
|
if (!input.shards) return 'unverified'
|
|
123
130
|
// A shard that never opened is not knowable either way; say so rather than
|
|
124
131
|
// claiming completeness or declaring failure.
|
|
@@ -205,8 +212,18 @@ export interface SyncRunOptions extends WindowOptions {
|
|
|
205
212
|
source?: string
|
|
206
213
|
scope?: string
|
|
207
214
|
limit?: number
|
|
208
|
-
/**
|
|
209
|
-
|
|
215
|
+
/**
|
|
216
|
+
* Injected for tests; the CLI wires this to chatService.
|
|
217
|
+
*
|
|
218
|
+
* `from` is a hint that a backend may push into SQL. It is never a
|
|
219
|
+
* correctness guarantee: runSync still applies the window itself, because a
|
|
220
|
+
* backend that cannot honour the lower bound reads everything instead.
|
|
221
|
+
*/
|
|
222
|
+
read: (
|
|
223
|
+
talker: string,
|
|
224
|
+
limit: number,
|
|
225
|
+
from: number | null
|
|
226
|
+
) => Promise<{ messages: Message[]; shards?: ShardReport }>
|
|
210
227
|
write?: boolean
|
|
211
228
|
/** Defaults to the real store; tests pass one rooted in a temp directory. */
|
|
212
229
|
store?: SyncStateStore
|
|
@@ -251,11 +268,13 @@ export async function runSync(talker: string, options: SyncRunOptions): Promise<
|
|
|
251
268
|
}
|
|
252
269
|
|
|
253
270
|
const limit = options.limit ?? 0
|
|
254
|
-
const read = await options.read(talker, limit)
|
|
271
|
+
const read = await options.read(talker, limit, window.from)
|
|
255
272
|
const { unique, duplicateCount } = dedupeMessages(read.messages, source, talker)
|
|
256
273
|
|
|
257
|
-
//
|
|
258
|
-
//
|
|
274
|
+
// The window is applied here as well as being pushed down, and this filter -
|
|
275
|
+
// not the pushdown - is what makes the result correct: a backend without
|
|
276
|
+
// range support returns the whole conversation, and one that cannot express
|
|
277
|
+
// the lower bound says so per shard rather than quietly dropping the bound.
|
|
259
278
|
const inWindow = window.from === null
|
|
260
279
|
? unique
|
|
261
280
|
: unique.filter(m => Number(m.createTime) >= (window.from as number))
|
|
@@ -270,10 +289,19 @@ export async function runSync(talker: string, options: SyncRunOptions): Promise<
|
|
|
270
289
|
const mayHaveMore = limit > 0 && read.messages.length >= limit
|
|
271
290
|
const coverage = deriveCoverage({ shards: read.shards, mayHaveMore })
|
|
272
291
|
if (!read.shards) warnings.push('shard-report-unavailable')
|
|
273
|
-
if (read.shards
|
|
292
|
+
if (read.shards) {
|
|
274
293
|
for (const item of read.shards.items) {
|
|
275
294
|
if (!item.opened) warnings.push(`shard-unverified:${item.name}`)
|
|
276
295
|
else if (item.reason === 'READ_FAILED') warnings.push(`shard-read-failed:${item.name}`)
|
|
296
|
+
// A shard the reader could not name anything in, or could not apply the
|
|
297
|
+
// window to. Both mean "this range was not read here", which must not
|
|
298
|
+
// pass as covered - hence a warning rather than a silent skip.
|
|
299
|
+
else if (item.reason) warnings.push(`shard-not-read:${item.name}:${item.reason}`)
|
|
300
|
+
// Read, but with fields missing. Column names are schema, not content,
|
|
301
|
+
// so this stays safe to record in the state file.
|
|
302
|
+
else if (item.missingColumns?.length) {
|
|
303
|
+
warnings.push(`shard-columns-missing:${item.name}:${item.missingColumns.join('+')}`)
|
|
304
|
+
}
|
|
277
305
|
}
|
|
278
306
|
}
|
|
279
307
|
|
|
@@ -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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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(
|
|
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(
|
|
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
|
}
|
|
@@ -1,15 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 微信读书 Agent API Gateway 封装
|
|
3
3
|
*
|
|
4
|
-
* 接口文档:
|
|
4
|
+
* 接口文档: https://weread.qq.com/r/weread-skills —— 登录后点「快速配置」,页面上给出
|
|
5
|
+
* `api-key`,并提供一个官方 skill 包(`https://cdn.weread.qq.com/skills/weread-skills.zip`)。
|
|
6
|
+
* **原来的注释指的是 `~/.claude/skills/weread-skills/`,那个目录在本机已不存在**
|
|
7
|
+
* (2026-09-28 核实),照它去找是找不到的。
|
|
5
8
|
* Gateway: https://i.weread.qq.com/api/agent/gateway
|
|
6
9
|
*/
|
|
7
10
|
|
|
8
11
|
import https from 'node:https'
|
|
12
|
+
import { configService } from './configService.js'
|
|
9
13
|
|
|
10
14
|
const GATEWAY = 'https://i.weread.qq.com/api/agent/gateway'
|
|
11
15
|
const SKILL_VERSION = '1.0.3'
|
|
12
16
|
|
|
17
|
+
/**
|
|
18
|
+
* 从两个来源里挑出可用的 key(环境变量优先,配置项兜底)。
|
|
19
|
+
*
|
|
20
|
+
* **为什么是两个来源**:助手的 `get_weread` 判据读**配置项** `wereadApiKey`
|
|
21
|
+
* (`assistantTools.ts` 的 `TOOL_REQUIREMENTS`),而 CLI 的 `weread` 命令一直只读
|
|
22
|
+
* **环境变量** `WEREAD_API_KEY`(官方那篇配置文档给的写法就是 export 环境变量)。
|
|
23
|
+
* 只认一个的后果是:用户按 CLI 的提示配好配置项,助手能用、`weread shelf` 却回你
|
|
24
|
+
* "未设置 WEREAD_API_KEY" —— 2026-09-28 实测踩到。
|
|
25
|
+
*
|
|
26
|
+
* 环境变量优先,因为官方文档明确建议"出于安全考虑,最好不要把 api-key 直接扔给 AI"。
|
|
27
|
+
* 两边都空白时返回空串,调用方按"没配"处理(`config set` 允许把值清空)。
|
|
28
|
+
*/
|
|
29
|
+
export function resolveWereadApiKey(fromEnv?: string | null, fromConfig?: string | null): string {
|
|
30
|
+
return String(fromEnv || '').trim() || String(fromConfig || '').trim()
|
|
31
|
+
}
|
|
32
|
+
|
|
13
33
|
// weread.qq.com 服务端不支持 TLS 1.3,需要强制使用 TLS 1.2
|
|
14
34
|
const httpsAgent = new https.Agent({ maxVersion: 'TLSv1.2' })
|
|
15
35
|
|
|
@@ -64,13 +84,20 @@ export interface ReadDetail {
|
|
|
64
84
|
|
|
65
85
|
export interface NotebookItem {
|
|
66
86
|
bookId: string
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
87
|
+
/**
|
|
88
|
+
* **书名与作者嵌在这里,条目顶层没有。** 实测(2026-09-28,`/user/notebooks` 的真实响应):
|
|
89
|
+
* 条目只有 `bookId` / `noteCount` / `bookmarkCount` / `readingProgress` / `sort` / `markedStatus`
|
|
90
|
+
* 等,读 `item.title` 会安静地拿到 `undefined` —— 工具照常输出,只是每本书都没名字。
|
|
91
|
+
*/
|
|
92
|
+
book?: { title?: string; author?: string; cover?: string }
|
|
93
|
+
/** 顶层这两个字段**不是**接口给的,留作兜底:网关换版本时形状可能不同。 */
|
|
94
|
+
title?: string
|
|
95
|
+
author?: string
|
|
96
|
+
cover?: string
|
|
71
97
|
noteCount: number
|
|
72
98
|
bookmarkCount: number
|
|
73
|
-
|
|
99
|
+
readingProgress?: number
|
|
100
|
+
sort?: number
|
|
74
101
|
}
|
|
75
102
|
|
|
76
103
|
export interface NoteDetail {
|
|
@@ -165,7 +192,16 @@ export class WereadService {
|
|
|
165
192
|
private apiKey: string
|
|
166
193
|
|
|
167
194
|
constructor(apiKey?: string) {
|
|
168
|
-
|
|
195
|
+
// 不传 key 时按 `resolveWereadApiKey` 的顺序自己找:环境变量 → 配置项。
|
|
196
|
+
//
|
|
197
|
+
// **配置项这一路是 2026-09-28 补的,而且是端到端才发现的。** 助手的 `get_weread`:
|
|
198
|
+
// 可用性判据读配置项(所以配好之后工具会出现在工具表里),执行时用的却是模块单例
|
|
199
|
+
// `new WereadService()` —— 那个单例只读环境变量。于是工具"看得见、调不动",回一句
|
|
200
|
+
// "未设置 WEREAD_API_KEY"。只改 CLI 那处是不够的:同一个密钥在当时有**三种读法**。
|
|
201
|
+
this.apiKey = apiKey || resolveWereadApiKey(
|
|
202
|
+
process.env.WEREAD_API_KEY,
|
|
203
|
+
String(configService.get('wereadApiKey') || ''),
|
|
204
|
+
)
|
|
169
205
|
}
|
|
170
206
|
|
|
171
207
|
private async call<T>(apiName: string, params: Record<string, any> = {}): Promise<WereadResult<T>> {
|
|
@@ -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
|
+
}
|
package/src/utils/dateRange.ts
CHANGED
|
@@ -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
|
+
}
|