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.
- package/ARCHITECTURE.md +3 -2
- package/CHANGELOG.md +1966 -0
- package/OPERATIONS.md +808 -0
- package/README.en.md +93 -7
- package/README.md +31 -6
- package/bin/weflow-cli.ts +1245 -26
- package/dist/bin/weflow-cli.js +1349 -72
- 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 +43 -0
- package/dist/src/core/ntCore.d.ts.map +1 -1
- package/dist/src/core/ntCore.js +31 -0
- 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 +18 -4
- 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 +590 -75
- package/dist/src/services/assistantService.js.map +1 -1
- package/dist/src/services/assistantTools.d.ts +205 -1
- package/dist/src/services/assistantTools.d.ts.map +1 -1
- package/dist/src/services/assistantTools.js +1089 -58
- 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 +13 -0
- package/dist/src/services/chatService.d.ts.map +1 -1
- package/dist/src/services/chatService.js +23 -0
- 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/messageQuery.d.ts +35 -0
- package/dist/src/services/messageQuery.d.ts.map +1 -1
- package/dist/src/services/messageQuery.js +61 -0
- package/dist/src/services/messageQuery.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 +127 -0
- package/dist/src/services/syncService.d.ts.map +1 -0
- package/dist/src/services/syncService.js +263 -0
- package/dist/src/services/syncService.js.map +1 -0
- package/dist/src/services/syncState.d.ts +113 -0
- package/dist/src/services/syncState.d.ts.map +1 -0
- package/dist/src/services/syncState.js +214 -0
- package/dist/src/services/syncState.js.map +1 -0
- 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/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/DECISIONS.md +1484 -20
- package/docs/EXTENDING.md +187 -0
- package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +338 -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 +299 -96
- package/docs/SETUP.md +22 -0
- package/docs/SYNC_CONTRACT.md +180 -0
- 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 +447 -0
- package/resources/panel/mascot-focus.png +0 -0
- package/resources/panel/mascot-happy.png +0 -0
- package/resources/panel/mascot-sorry.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 +407 -0
- package/resources/panel/preload.cjs +72 -0
- package/resources/panel/quick-menu.cjs +197 -0
- package/resources/panel/renderer.js +441 -0
- package/resources/panel/tray-menu.cjs +40 -0
- package/resources/panel/tray.png +0 -0
- package/scripts/_utils.py +403 -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 +1059 -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 +212 -46
- 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 -1352
- 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 +397 -0
- package/src/core/appMsgFormat.ts +117 -0
- package/src/core/ntCore.ts +67 -0
- 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 +18 -4
- package/src/services/assistantRouter.ts +191 -0
- package/src/services/assistantService.ts +671 -74
- package/src/services/assistantTools.ts +1118 -57
- package/src/services/assistantTrace.ts +175 -0
- package/src/services/chatService.ts +30 -0
- package/src/services/configService.ts +403 -385
- package/src/services/messageQuery.ts +97 -0
- package/src/services/pythonBridge.ts +139 -0
- package/src/services/syncService.ts +363 -0
- package/src/services/syncState.ts +275 -0
- package/src/services/wechatMessageService.ts +73 -7
- package/src/utils/atomicWrite.ts +56 -0
- package/src/utils/dateRange.ts +46 -0
- package/src/utils/text.ts +20 -0
package/bin/weflow-cli.ts
CHANGED
|
@@ -5,6 +5,8 @@ import inquirer from 'inquirer'
|
|
|
5
5
|
import { basename, join } from 'path'
|
|
6
6
|
import { existsSync, readFileSync, statSync, writeFileSync } from 'fs'
|
|
7
7
|
import { homedir } from 'os'
|
|
8
|
+
import { SYNC_SCHEMA, SYNC_SOURCE, syncStateStore } from '../src/services/syncState.js'
|
|
9
|
+
import { SyncRangeRequiredError, runSync } from '../src/services/syncService.js'
|
|
8
10
|
import { dbPathService } from '../src/core/dbPathService.js'
|
|
9
11
|
import { keyService } from '../src/core/keyService.js'
|
|
10
12
|
import { NtCore } from '../src/core/ntCore.js'
|
|
@@ -316,8 +318,41 @@ program
|
|
|
316
318
|
dailyStats: { cli: 'daily-stats --json' },
|
|
317
319
|
diagnostics: { cli: 'check --json' },
|
|
318
320
|
todos: { cli: 'todos list --json' },
|
|
321
|
+
awaiting: {
|
|
322
|
+
cli: 'awaiting --yes',
|
|
323
|
+
preview: 'awaiting --dry-run --json',
|
|
324
|
+
readsLocalChat: true,
|
|
325
|
+
invokesAI: true,
|
|
326
|
+
writesNothing: true,
|
|
327
|
+
confirmationRequired: true,
|
|
328
|
+
},
|
|
329
|
+
draft: {
|
|
330
|
+
cli: 'draft <会话> --yes --json',
|
|
331
|
+
preview: 'draft <会话> --dry-run --json',
|
|
332
|
+
readsLocalChat: true,
|
|
333
|
+
invokesAI: true,
|
|
334
|
+
// **它不发送任何东西**:只产出候选文本,回不回由人决定。
|
|
335
|
+
// 这条要能被机器读到——`sendsNothing` 比 `writesNothing` 更要紧。
|
|
336
|
+
writesNothing: true,
|
|
337
|
+
sendsNothing: true,
|
|
338
|
+
confirmationRequired: true,
|
|
339
|
+
},
|
|
319
340
|
knowledge: { cli: 'search <query> --yes --json', preview: 'search <query> --dry-run --json', output: 'json', mcp: 'wechat.search_articles' },
|
|
320
341
|
},
|
|
342
|
+
primitives: {
|
|
343
|
+
decide: {
|
|
344
|
+
cli: 'decide --request <file> --yes | decide --over <glob> --ask <text> --yes',
|
|
345
|
+
preview: 'decide --dry-run',
|
|
346
|
+
// 两条路的读法**不一样**,所以不能只写一个 false:
|
|
347
|
+
// --request 完全由调用方给,--over 会读匹配到的本地文件。
|
|
348
|
+
readsLocalData: { request: false, over: true },
|
|
349
|
+
invokesAI: true,
|
|
350
|
+
sendsCallerProvidedState: true,
|
|
351
|
+
confirmationRequired: true,
|
|
352
|
+
mcpExposed: false,
|
|
353
|
+
whyNotMcp: 'an MCP client must not be able to drive local outbound calls',
|
|
354
|
+
},
|
|
355
|
+
},
|
|
321
356
|
workflows: {
|
|
322
357
|
initialization: {
|
|
323
358
|
preview: 'init --dry-run --json',
|
|
@@ -327,6 +362,26 @@ program
|
|
|
327
362
|
previewExposesPaths: false,
|
|
328
363
|
},
|
|
329
364
|
evidencePackage: { cli: 'evidence <talker> --json --non-interactive', localOnly: true },
|
|
365
|
+
sync: {
|
|
366
|
+
preview: 'sync run <talker> --dry-run --json',
|
|
367
|
+
execute: 'sync run <talker> --yes --json',
|
|
368
|
+
status: 'sync status [<talker>] --json',
|
|
369
|
+
verify: 'sync verify <talker> --json',
|
|
370
|
+
confirmationRequired: true,
|
|
371
|
+
readsLocalData: true,
|
|
372
|
+
invokesAI: false,
|
|
373
|
+
sideEffects: ['local-checkpoint-file'],
|
|
374
|
+
stateSchema: 'weflow-sync/v1',
|
|
375
|
+
jobSchema: 'weflow-job/v1',
|
|
376
|
+
stateLocation: 'local-user-config-dir',
|
|
377
|
+
previewResolvesTalker: false,
|
|
378
|
+
previewExposesLocalPaths: false,
|
|
379
|
+
// D-027: a stable cursor must be backed by every database backend
|
|
380
|
+
// before it is advertised. This exposes overlapping windows only.
|
|
381
|
+
stableCursor: false,
|
|
382
|
+
coverageValues: ['complete', 'unverified', 'partial'],
|
|
383
|
+
partialCompletesSuccessfully: false,
|
|
384
|
+
},
|
|
330
385
|
aiAnalysis: {
|
|
331
386
|
preview: 'evidence-review <talker> --dry-run --json',
|
|
332
387
|
execute: 'evidence-review <talker> --yes --json',
|
|
@@ -370,6 +425,17 @@ program
|
|
|
370
425
|
execute: 'assistant <start|stop> --yes --json',
|
|
371
426
|
confirmationRequired: true,
|
|
372
427
|
},
|
|
428
|
+
localPanel: {
|
|
429
|
+
status: 'panel --status --json',
|
|
430
|
+
ask: 'panel --ask "<text>" --yes --json',
|
|
431
|
+
preview: 'panel --dry-run --json',
|
|
432
|
+
execute: 'panel --yes --json',
|
|
433
|
+
confirmationRequired: true,
|
|
434
|
+
opensLocalWindow: true,
|
|
435
|
+
loopbackOnly: true,
|
|
436
|
+
// 关掉窗口**不会**停掉助手——这件事必须能被机器读到,别让调用方以为退出即结束
|
|
437
|
+
keepsAssistantRunningAfterClose: true,
|
|
438
|
+
},
|
|
373
439
|
messageChannelAuthentication: {
|
|
374
440
|
loginPreview: 'login-wechat --dry-run --json',
|
|
375
441
|
loginExecute: 'login-wechat --yes',
|
|
@@ -499,7 +565,28 @@ program
|
|
|
499
565
|
aiDisabledByDefaultForExport: true,
|
|
500
566
|
unknownMessageTypesPreserved: true,
|
|
501
567
|
nonInteractiveTalkerResolution: true,
|
|
502
|
-
|
|
568
|
+
// **这个名字曾经是假的**:MCP 那张表不是手写的只读清单,而是**助手工具表减去
|
|
569
|
+
// save_memory**,所以里面早就有写文件的 `export_chat` 和会把图片交给模型的
|
|
570
|
+
// `look_at_image`。如实改成 false,并把真正有出境的几样单独列出来(下面这个对象)。
|
|
571
|
+
// 读这一位的调用方按 false 走保守分支即可。
|
|
572
|
+
mcpDefaultReadOnly: false,
|
|
573
|
+
mcpSurface: {
|
|
574
|
+
// 原文只写了 "minus save_memory" —— 而那张表也排除了 `look_at_image`(MCP 只取文本,
|
|
575
|
+
// 它挂进侧信道的图没人接)。机器可读字段少说一句同样算不准,如实写全。
|
|
576
|
+
derivedFrom: 'assistant tools minus save_memory and look_at_image',
|
|
577
|
+
writesFiles: ['export_chat'],
|
|
578
|
+
// 逐个核实过(脚本里确实调云端模型的那些):
|
|
579
|
+
// who_owes_reply/search_chats → Jev;search_semantic → 阿里云百炼嵌入 + Jev 重排;draft_reply → Jev + DeepSeek。
|
|
580
|
+
// `look_at_image` **不在这里**:它靠侧信道把图交给助手自己的模型,而 MCP 只取工具返回的文本,
|
|
581
|
+
// 图没人接、回话却写着"你能看到它了"——所以它已经从 MCP 那张表里排除了(见 MCP_TOOL_DEFS)。
|
|
582
|
+
callsCloudModels: ['who_owes_reply', 'search_chats', 'search_semantic', 'draft_reply'],
|
|
583
|
+
// 机器调用**默认只给预览**、必须显式 confirm 才出境的那些(MCP 独有的边界)。
|
|
584
|
+
// **这里是四个,不是"只有 draft_reply"**:逐个读 handler 才发现那四个工具里
|
|
585
|
+
// 写的是同一句 `if (ctx.requiresConfirm && args.confirm !== true)`(1010/1041/1094/1170)。
|
|
586
|
+
// 原先只列了 draft_reply,而测试是照着这个字段的值抄的,于是两处一起错、谁都没红。
|
|
587
|
+
// 现在测试改成**从源码里数**(见 test/assistant-tools.test.ts),改一处不同步就会红。
|
|
588
|
+
requiresConfirm: ['search_chats', 'who_owes_reply', 'draft_reply', 'search_semantic'],
|
|
589
|
+
},
|
|
503
590
|
mcpMessageLimit: { default: 100, max: 1000 },
|
|
504
591
|
},
|
|
505
592
|
}
|
|
@@ -919,9 +1006,13 @@ const configCmd = program
|
|
|
919
1006
|
const configurableKeys = [
|
|
920
1007
|
'dbPath', 'decryptKey', 'dbPath3x', 'decryptKey3x', 'dataVersion', 'wxid',
|
|
921
1008
|
'ntDbPath', 'ntKey', 'ntSalt', 'contactDbPath', 'contactKey', 'contactSalt',
|
|
922
|
-
'vaultRepo', 'aiEngine', 'aiBaseUrl', 'aiModel', 'deepseekApiKey', '
|
|
1009
|
+
'vaultRepo', 'aiEngine', 'aiBaseUrl', 'aiModel', 'deepseekApiKey', 'typesafeApiKey',
|
|
1010
|
+
'dashscopeApiKey', 'favPassphrase',
|
|
1011
|
+
'wereadApiKey',
|
|
923
1012
|
'assistantPrivacy', 'assistantWhitelist', 'assistantGroupWhitelist',
|
|
924
|
-
'assistantGroupRequireMention', '
|
|
1013
|
+
'assistantGroupRequireMention', 'assistantFastRoute', 'assistantPanelUser', 'quickReplyContacts',
|
|
1014
|
+
'dailySources', 'dailySourceCategories',
|
|
1015
|
+
'dailyExcludeTopics', 'dailyAiEnabled',
|
|
925
1016
|
'emoticonSeed',
|
|
926
1017
|
] as const
|
|
927
1018
|
|
|
@@ -1010,6 +1101,7 @@ configCmd
|
|
|
1010
1101
|
console.log(`微信账号: ${config.wxid || chalk.gray('(未设置)')}`)
|
|
1011
1102
|
console.log(`公众号日报来源: ${configService.get('dailySources') || chalk.gray('(全部公众号)')}`)
|
|
1012
1103
|
console.log(`公众号日报 AI: ${configService.get('dailyAiEnabled') === 'false' ? '已关闭' : '已开启'}`)
|
|
1104
|
+
console.log(`公众号日报排除主题: ${configService.get('dailyExcludeTopics') || chalk.gray('(不排除)')}`)
|
|
1013
1105
|
})
|
|
1014
1106
|
|
|
1015
1107
|
configCmd
|
|
@@ -1237,6 +1329,296 @@ program
|
|
|
1237
1329
|
}
|
|
1238
1330
|
})
|
|
1239
1331
|
|
|
1332
|
+
// ==================== sync ====================
|
|
1333
|
+
// 增量同步检查点。契约见 docs/SYNC_CONTRACT.md, 边界见 D-029。
|
|
1334
|
+
// 不宣称「稳定游标」: D-027 要求它在所有后端支持前不得对外宣称,
|
|
1335
|
+
// 这里只做重叠时间窗 + 本地去重。
|
|
1336
|
+
const syncCmd = program.command('sync').description('本地消息同步检查点(重叠窗口读取与覆盖报告)')
|
|
1337
|
+
|
|
1338
|
+
syncCmd
|
|
1339
|
+
.command('run <talker>')
|
|
1340
|
+
.description('执行一次同步并写入检查点')
|
|
1341
|
+
.option('--since <date>', '起始日期或 ISO 时间(首次运行需要)')
|
|
1342
|
+
.option('--full', '读取全部历史(首次运行需要;耗时可能较长)')
|
|
1343
|
+
.option('--overlap <seconds>', '重叠窗口秒数', '300')
|
|
1344
|
+
.option('-n, --limit <number>', '本次最多读取条数(0=不限)', '0')
|
|
1345
|
+
.option('--dry-run', '仅预览,不读取消息、不写状态')
|
|
1346
|
+
.option('--yes', '确认执行')
|
|
1347
|
+
.option('--json', '输出机器可读结果')
|
|
1348
|
+
.option('--non-interactive', '禁止交互确认')
|
|
1349
|
+
.action(async (talker: string, opts) => {
|
|
1350
|
+
const overlap = parseCliInteger(opts.overlap, 'overlap', 0, 86400, !!opts.json)
|
|
1351
|
+
const limit = parseCliInteger(opts.limit, 'limit', 0, 1_000_000, !!opts.json)
|
|
1352
|
+
|
|
1353
|
+
let since: number | undefined
|
|
1354
|
+
if (opts.since) {
|
|
1355
|
+
try {
|
|
1356
|
+
since = parseLocalDateOrIso(opts.since)
|
|
1357
|
+
} catch (error) {
|
|
1358
|
+
const message = error instanceof DateRangeError ? error.message : '日期格式无效'
|
|
1359
|
+
if (opts.json) { console.log(JSON.stringify({ success: false, code: 'INVALID_DATE', error: message })); process.exit(1) }
|
|
1360
|
+
console.error(chalk.red(`✗ ${message}`))
|
|
1361
|
+
process.exit(1)
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
if (opts.full && since !== undefined) {
|
|
1365
|
+
if (opts.json) { console.log(JSON.stringify({ success: false, code: 'INVALID_ARGUMENT', error: '--since 与 --full 不能同时使用' })); process.exit(1) }
|
|
1366
|
+
console.error(chalk.red('✗ --since 与 --full 不能同时使用'))
|
|
1367
|
+
process.exit(1)
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
// Preview deliberately does not resolve the talker: that reads the
|
|
1371
|
+
// database, and a preview must not touch local data (D-025).
|
|
1372
|
+
const prior = (() => {
|
|
1373
|
+
try { return syncStateStore.read(SYNC_SOURCE, talker) } catch { return null }
|
|
1374
|
+
})()
|
|
1375
|
+
const preview = {
|
|
1376
|
+
success: true,
|
|
1377
|
+
dryRun: true,
|
|
1378
|
+
action: 'sync.run',
|
|
1379
|
+
schema: SYNC_SCHEMA,
|
|
1380
|
+
source: SYNC_SOURCE,
|
|
1381
|
+
talkerResolved: false,
|
|
1382
|
+
hasCheckpoint: !!prior,
|
|
1383
|
+
window: {
|
|
1384
|
+
from: since ?? prior?.checkpoint.newestCreateTime ?? null,
|
|
1385
|
+
overlapSeconds: overlap,
|
|
1386
|
+
full: !!opts.full,
|
|
1387
|
+
},
|
|
1388
|
+
estimatedRecords: prior?.recordsRead ?? null,
|
|
1389
|
+
readsLocalChat: true,
|
|
1390
|
+
writesLocalState: true,
|
|
1391
|
+
stableCursor: false,
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
if (opts.dryRun) {
|
|
1395
|
+
if (opts.json) console.log(JSON.stringify(preview))
|
|
1396
|
+
else {
|
|
1397
|
+
console.log(chalk.cyan('\n同步预览'))
|
|
1398
|
+
console.log(chalk.gray(` 范围: ${opts.full ? '全部历史' : since ? new Date(since * 1000).toLocaleString() : '上次检查点'}`))
|
|
1399
|
+
console.log(chalk.gray(` 重叠窗口: ${overlap} 秒`))
|
|
1400
|
+
console.log(chalk.gray(' 将读取本地聊天记录并写入同步检查点,不调用 AI。\n'))
|
|
1401
|
+
}
|
|
1402
|
+
return
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
if (!opts.yes) {
|
|
1406
|
+
if (opts.json) {
|
|
1407
|
+
console.log(JSON.stringify({ ...preview, success: false, dryRun: false, code: 'CONFIRMATION_REQUIRED' }))
|
|
1408
|
+
process.exit(1)
|
|
1409
|
+
}
|
|
1410
|
+
if (opts.nonInteractive) {
|
|
1411
|
+
console.error(chalk.red('✗ 非交互模式需要 --yes'))
|
|
1412
|
+
process.exit(1)
|
|
1413
|
+
}
|
|
1414
|
+
const { confirmed } = await inquirer.prompt([{
|
|
1415
|
+
type: 'confirm',
|
|
1416
|
+
name: 'confirmed',
|
|
1417
|
+
message: opts.full
|
|
1418
|
+
? '将读取该会话的全部历史消息,可能耗时数分钟。继续吗?'
|
|
1419
|
+
: '将读取指定范围内的消息并写入本地同步检查点。继续吗?',
|
|
1420
|
+
default: false,
|
|
1421
|
+
}])
|
|
1422
|
+
if (!confirmed) {
|
|
1423
|
+
console.log(chalk.gray('已取消'))
|
|
1424
|
+
return
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
if (!configService.isConfigured()) {
|
|
1429
|
+
if (opts.json) { console.log(JSON.stringify({ success: false, code: 'NOT_INITIALIZED', error: '未完成初始化' })); process.exit(1) }
|
|
1430
|
+
console.error(chalk.red('✗ 未完成初始化,请先运行 weflow-cli init'))
|
|
1431
|
+
process.exit(1)
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
let resolvedTalker = ''
|
|
1435
|
+
try {
|
|
1436
|
+
resolvedTalker = await resolveTalkerCore(talker, { interactive: !opts.nonInteractive })
|
|
1437
|
+
} catch {
|
|
1438
|
+
resolvedTalker = ''
|
|
1439
|
+
}
|
|
1440
|
+
if (!resolvedTalker) {
|
|
1441
|
+
if (opts.json) { console.log(JSON.stringify({ success: false, code: 'INVALID_ARGUMENT', error: '未找到该会话' })); process.exit(1) }
|
|
1442
|
+
console.error(chalk.red('✗ 未找到该会话'))
|
|
1443
|
+
process.exit(1)
|
|
1444
|
+
}
|
|
1445
|
+
// A display name is nicety only; the state is keyed by the resolved id.
|
|
1446
|
+
let displayName = resolvedTalker
|
|
1447
|
+
try {
|
|
1448
|
+
const match = (await chatService.listSessions()).find(s => s.username === resolvedTalker)
|
|
1449
|
+
if (match?.displayName && match.displayName !== resolvedTalker) displayName = match.displayName
|
|
1450
|
+
} catch { /* keep the id */ }
|
|
1451
|
+
|
|
1452
|
+
try {
|
|
1453
|
+
const result = await runSync(resolvedTalker, {
|
|
1454
|
+
since, full: !!opts.full, overlapSeconds: overlap, limit,
|
|
1455
|
+
scope: displayName,
|
|
1456
|
+
read: (who, howMany, from) => chatService.getMessagesWithShards(who, howMany, 0, from),
|
|
1457
|
+
})
|
|
1458
|
+
if (opts.json) {
|
|
1459
|
+
console.log(JSON.stringify({
|
|
1460
|
+
success: result.success,
|
|
1461
|
+
...(result.code ? { code: result.code } : {}),
|
|
1462
|
+
action: result.action,
|
|
1463
|
+
schema: SYNC_SCHEMA,
|
|
1464
|
+
source: SYNC_SOURCE,
|
|
1465
|
+
scope: result.scope,
|
|
1466
|
+
talker: resolvedTalker,
|
|
1467
|
+
window: result.window,
|
|
1468
|
+
recordsRead: result.recordsRead,
|
|
1469
|
+
recordsDeduplicated: result.recordsDeduplicated,
|
|
1470
|
+
recordsReturned: result.recordsReturned,
|
|
1471
|
+
shards: result.shards,
|
|
1472
|
+
coverage: result.coverage,
|
|
1473
|
+
mayHaveMore: result.mayHaveMore,
|
|
1474
|
+
partial: result.partial,
|
|
1475
|
+
warnings: result.warnings,
|
|
1476
|
+
jobId: result.jobId,
|
|
1477
|
+
}))
|
|
1478
|
+
if (!result.success) process.exit(1)
|
|
1479
|
+
return
|
|
1480
|
+
}
|
|
1481
|
+
const mark = result.coverage === 'complete' ? '✓' : result.coverage === 'unverified' ? '!' : '✗'
|
|
1482
|
+
console.log(chalk.green(`\n${mark} 同步完成: ${result.recordsReturned}/${result.recordsRead} 条(去重 ${result.recordsDeduplicated})`))
|
|
1483
|
+
console.log(chalk.gray(` 覆盖: ${result.coverage} · 分片: ${result.shards?.opened ?? '?'} 读 / ${result.shards?.failed ?? 0} 失败`))
|
|
1484
|
+
for (const warning of result.warnings) console.log(chalk.yellow(` ⚠ ${warning}`))
|
|
1485
|
+
if (result.partial) {
|
|
1486
|
+
console.log(chalk.yellow(' 本次为部分完成,未推进成功时间;用 sync status 查看详情。'))
|
|
1487
|
+
}
|
|
1488
|
+
console.log('')
|
|
1489
|
+
if (!result.success) process.exit(1)
|
|
1490
|
+
} catch (error: any) {
|
|
1491
|
+
if (error instanceof SyncRangeRequiredError) {
|
|
1492
|
+
if (opts.json) { console.log(JSON.stringify({ success: false, code: 'SYNC_RANGE_REQUIRED', error: error.message })); process.exit(1) }
|
|
1493
|
+
console.error(chalk.red(`✗ ${error.message}`))
|
|
1494
|
+
process.exit(1)
|
|
1495
|
+
}
|
|
1496
|
+
if (opts.json) { console.log(JSON.stringify({ success: false, code: 'SYNC_FAILED', error: '同步失败' })); process.exit(1) }
|
|
1497
|
+
console.error(chalk.red(`✗ 同步失败: ${error?.message || error}`))
|
|
1498
|
+
process.exit(1)
|
|
1499
|
+
}
|
|
1500
|
+
})
|
|
1501
|
+
|
|
1502
|
+
syncCmd
|
|
1503
|
+
.command('status [talker]')
|
|
1504
|
+
.description('查看同步检查点与最近任务')
|
|
1505
|
+
.option('--json', '输出 JSON 格式')
|
|
1506
|
+
.action(async (talker: string | undefined, opts) => {
|
|
1507
|
+
// No database access: this has to work while the database is locked.
|
|
1508
|
+
let states: any[] = []
|
|
1509
|
+
try { states = syncStateStore.list() } catch { states = [] }
|
|
1510
|
+
const lastJob = syncStateStore.recentJobs(1)[0] ?? null
|
|
1511
|
+
|
|
1512
|
+
if (talker) {
|
|
1513
|
+
const match = states.find(s => s.talker === talker || s.scope === talker)
|
|
1514
|
+
if (!match) {
|
|
1515
|
+
if (opts.json) { console.log(JSON.stringify({ success: false, code: 'SYNC_STATE_NOT_FOUND', error: '没有该会话的同步记录' })); process.exit(1) }
|
|
1516
|
+
console.error(chalk.red('✗ 没有该会话的同步记录'))
|
|
1517
|
+
process.exit(1)
|
|
1518
|
+
}
|
|
1519
|
+
if (opts.json) { console.log(JSON.stringify({ success: true, schema: SYNC_SCHEMA, state: match, lastJob })); return }
|
|
1520
|
+
console.log(chalk.cyan(`\n同步记录 — ${match.scope}`))
|
|
1521
|
+
console.log(chalk.gray(` 覆盖: ${match.coveredFrom || '—'} ~ ${match.coveredTo || '—'}`))
|
|
1522
|
+
console.log(chalk.gray(` 可信度: ${match.coverage} · 分片 ${match.shardsRead} 读 / ${match.shardsFailed} 失败`))
|
|
1523
|
+
console.log(chalk.gray(` 上次成功: ${match.lastSuccessfulRun || '—'} · 上次尝试: ${match.lastAttempt || '—'}\n`))
|
|
1524
|
+
return
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
if (opts.json) {
|
|
1528
|
+
console.log(JSON.stringify({
|
|
1529
|
+
success: true,
|
|
1530
|
+
schema: SYNC_SCHEMA,
|
|
1531
|
+
stableCursor: false,
|
|
1532
|
+
scopes: states.map(s => ({
|
|
1533
|
+
source: s.source, scope: s.scope, talker: s.talker,
|
|
1534
|
+
lastSuccessfulRun: s.lastSuccessfulRun, lastAttempt: s.lastAttempt,
|
|
1535
|
+
coveredFrom: s.coveredFrom, coveredTo: s.coveredTo,
|
|
1536
|
+
recordsRead: s.recordsRead, recordsDeduplicated: s.recordsDeduplicated,
|
|
1537
|
+
coverage: s.coverage, shardsRead: s.shardsRead, shardsFailed: s.shardsFailed,
|
|
1538
|
+
mayHaveMore: s.mayHaveMore, warnings: s.warnings,
|
|
1539
|
+
})),
|
|
1540
|
+
lastJob,
|
|
1541
|
+
}))
|
|
1542
|
+
return
|
|
1543
|
+
}
|
|
1544
|
+
if (!states.length) {
|
|
1545
|
+
console.log(chalk.gray('\n还没有任何同步记录。首次运行: weflow-cli sync run <会话> --since <日期>\n'))
|
|
1546
|
+
return
|
|
1547
|
+
}
|
|
1548
|
+
console.log(chalk.cyan(`\n同步记录 (${states.length} 个会话):\n`))
|
|
1549
|
+
for (const s of states) {
|
|
1550
|
+
const mark = s.coverage === 'complete' ? '✓' : s.coverage === 'unverified' ? '!' : '✗'
|
|
1551
|
+
console.log(` ${mark} ${s.scope} ${chalk.gray(`${s.coveredFrom || '—'} ~ ${s.coveredTo || '—'} 分片 ${s.shardsRead}/${s.shardsFailed} 失败`)}`)
|
|
1552
|
+
}
|
|
1553
|
+
console.log('')
|
|
1554
|
+
})
|
|
1555
|
+
|
|
1556
|
+
syncCmd
|
|
1557
|
+
.command('verify <talker>')
|
|
1558
|
+
.description('核对检查点与当前数据的边界')
|
|
1559
|
+
.option('--json', '输出 JSON 格式')
|
|
1560
|
+
.action(async (talker: string, opts) => {
|
|
1561
|
+
// Read-only: it writes nothing, so it needs no confirmation - the same
|
|
1562
|
+
// posture as `messages`.
|
|
1563
|
+
if (!configService.isConfigured()) {
|
|
1564
|
+
if (opts.json) { console.log(JSON.stringify({ success: false, code: 'NOT_INITIALIZED', error: '未完成初始化' })); process.exit(1) }
|
|
1565
|
+
console.error(chalk.red('✗ 未完成初始化'))
|
|
1566
|
+
process.exit(1)
|
|
1567
|
+
}
|
|
1568
|
+
let state: any = null
|
|
1569
|
+
try { state = syncStateStore.read(SYNC_SOURCE, talker) } catch { state = null }
|
|
1570
|
+
if (!state) state = syncStateStore.list().find(s => s.scope === talker) ?? null
|
|
1571
|
+
if (!state) {
|
|
1572
|
+
if (opts.json) { console.log(JSON.stringify({ success: false, code: 'SYNC_STATE_NOT_FOUND', error: '没有该会话的同步记录' })); process.exit(1) }
|
|
1573
|
+
console.error(chalk.red('✗ 没有该会话的同步记录'))
|
|
1574
|
+
process.exit(1)
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
const checks: Array<Record<string, unknown>> = [{ name: 'checkpoint-present', ok: true }]
|
|
1578
|
+
|
|
1579
|
+
// Re-read and compare. This is the count summary of §4.4 in the absence
|
|
1580
|
+
// of a local index.
|
|
1581
|
+
const read = await chatService.getMessagesWithShards(state.talker, 0, 0)
|
|
1582
|
+
const fromSeconds = state.coveredFrom ? Math.floor(Date.parse(state.coveredFrom) / 1000) : null
|
|
1583
|
+
const inWindow = fromSeconds === null
|
|
1584
|
+
? read.messages
|
|
1585
|
+
: read.messages.filter(m => Number(m.createTime) >= fromSeconds)
|
|
1586
|
+
checks.push({ name: 'window-re-readable', ok: inWindow.length > 0, returned: inWindow.length })
|
|
1587
|
+
|
|
1588
|
+
const newest = read.messages.length
|
|
1589
|
+
? Math.max(...read.messages.map(m => Number(m.createTime) || 0))
|
|
1590
|
+
: null
|
|
1591
|
+
checks.push({
|
|
1592
|
+
name: 'newest-message-present',
|
|
1593
|
+
ok: newest !== null && newest >= (state.checkpoint?.newestCreateTime ?? 0),
|
|
1594
|
+
recorded: state.checkpoint?.newestCreateTime ?? null,
|
|
1595
|
+
observed: newest,
|
|
1596
|
+
})
|
|
1597
|
+
|
|
1598
|
+
if (read.shards) {
|
|
1599
|
+
checks.push({ name: 'shards-readable', ok: read.shards.failed === 0,
|
|
1600
|
+
scanned: read.shards.scanned, failed: read.shards.failed })
|
|
1601
|
+
} else {
|
|
1602
|
+
// Neither a pass nor a failure: this backend simply cannot say.
|
|
1603
|
+
checks.push({ name: 'shards-readable', ok: true, skipped: true,
|
|
1604
|
+
reason: 'backend-does-not-report-shards' })
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
const success = checks.every(c => c.ok || c.skipped)
|
|
1608
|
+
if (opts.json) {
|
|
1609
|
+
console.log(JSON.stringify({ success, schema: SYNC_SCHEMA, scope: state.scope, coverage: state.coverage, checks }))
|
|
1610
|
+
if (!success) process.exit(1)
|
|
1611
|
+
return
|
|
1612
|
+
}
|
|
1613
|
+
console.log(chalk.cyan(`\n核对 — ${state.scope}\n`))
|
|
1614
|
+
for (const check of checks) {
|
|
1615
|
+
const mark = check.skipped ? '–' : check.ok ? '✓' : '✗'
|
|
1616
|
+
console.log(` ${mark} ${check.name}${check.skipped ? chalk.gray(` (跳过: ${check.reason})`) : ''}`)
|
|
1617
|
+
}
|
|
1618
|
+
console.log('')
|
|
1619
|
+
if (!success) process.exit(1)
|
|
1620
|
+
})
|
|
1621
|
+
|
|
1240
1622
|
// ==================== contacts ====================
|
|
1241
1623
|
program
|
|
1242
1624
|
.command('contacts')
|
|
@@ -1654,12 +2036,26 @@ program
|
|
|
1654
2036
|
process.exit(1)
|
|
1655
2037
|
}
|
|
1656
2038
|
if (!opts.yes) {
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
2039
|
+
// stdin 不是终端时(管道、计划任务、会话里的 `!` 前缀执行)inquirer 会抛 ExitPromptError——
|
|
2040
|
+
// 屏幕上是一条堆栈而不是一句话。这里把它变成解释:扫码登录必须在真实终端里跑。
|
|
2041
|
+
// 与守护进程那条 `--yes` 是同一类毛病:交互确认被塞进一个没人能回答的 stdin。
|
|
2042
|
+
let confirmed = false
|
|
2043
|
+
try {
|
|
2044
|
+
const answer = await inquirer.prompt([{
|
|
2045
|
+
type: 'confirm',
|
|
2046
|
+
name: 'confirmed',
|
|
2047
|
+
message: token ? '已有登录状态,确认启动重新登录吗?' : '确认启动人工扫码登录吗?',
|
|
2048
|
+
default: false,
|
|
2049
|
+
}])
|
|
2050
|
+
confirmed = !!answer.confirmed
|
|
2051
|
+
} catch (error: any) {
|
|
2052
|
+
if (error?.name === 'ExitPromptError' || /force closed the prompt/.test(String(error?.message))) {
|
|
2053
|
+
console.log(chalk.yellow('当前环境没有可交互的终端。扫码登录必须在真实终端窗口里执行,'))
|
|
2054
|
+
console.log(chalk.yellow('或者加 --yes 跳过这个确认(扫码那一步仍然要人来做)。'))
|
|
2055
|
+
process.exit(1)
|
|
2056
|
+
}
|
|
2057
|
+
throw error
|
|
2058
|
+
}
|
|
1663
2059
|
if (!confirmed) {
|
|
1664
2060
|
console.log(chalk.gray(token ? '保持当前登录状态' : '已取消'))
|
|
1665
2061
|
return
|
|
@@ -1687,6 +2083,20 @@ program
|
|
|
1687
2083
|
if (session.status === 'confirmed') {
|
|
1688
2084
|
console.log(chalk.green('\n✓ 登录成功!'))
|
|
1689
2085
|
console.log(chalk.gray(' 消息通道登录状态已安全保存'))
|
|
2086
|
+
// 助手的白名单为空时**拒绝所有人**:登录完对着自己的机器人说话,什么都不会回来。
|
|
2087
|
+
// 所以这里必须说清"下一步该干什么"。
|
|
2088
|
+
// 只打在人看的这条路上——`--json` 那条路照旧不返回账号标识。
|
|
2089
|
+
console.log('')
|
|
2090
|
+
console.log(chalk.cyan(' 助手默认拒绝所有人(白名单为空)。'))
|
|
2091
|
+
// 这里**不**替用户写白名单:登录响应给的是 ilink_user_id,而白名单要的是入站消息里
|
|
2092
|
+
// 那个 from_user_id(文档里写成 <@im.wechat ID>),两者是不是同一个值**没有被验证过**
|
|
2093
|
+
// ——猜错的后果是"白名单非空、看着配好了、却仍然拒你"。真值在第一条被拒的消息里,
|
|
2094
|
+
// 所以让它自己现形(见 assistant log 的 [首次配置] 那行)。
|
|
2095
|
+
console.log(chalk.gray(' 启用方式:先 assistant start,从你的微信给机器人发一条消息,'))
|
|
2096
|
+
console.log(chalk.gray(' 日志里会出现一行 ' + chalk.cyan('[首次配置]')
|
|
2097
|
+
+ chalk.gray(' 带着你自己的发送者 ID 和该执行的命令。')))
|
|
2098
|
+
console.log(chalk.gray(' 只想先看它「本来会怎么答」而不改行为:')
|
|
2099
|
+
+ chalk.cyan('weflow-cli config set assistantFastRoute log'))
|
|
1690
2100
|
} else {
|
|
1691
2101
|
console.log(chalk.red(`\n✗ 登录失败: ${session.error || '超时'}`))
|
|
1692
2102
|
}
|
|
@@ -3178,13 +3588,18 @@ program
|
|
|
3178
3588
|
'005_Reference/Tools',
|
|
3179
3589
|
'005_Reference/Methods',
|
|
3180
3590
|
'006_Projects',
|
|
3181
|
-
|
|
3591
|
+
// 这里原先还有 `007_Wiki/Concepts`:概念页实际写在下面的顶层 `Wiki/Concepts`
|
|
3592
|
+
// (`compile_wiki` 的 OUTPUT_ROOT)。两个都列,就建出一个永远空着的目录。
|
|
3182
3593
|
'008_MOC',
|
|
3183
3594
|
'999_Archive',
|
|
3184
3595
|
'_attachments',
|
|
3596
|
+
// `Wiki/Concepts` 是唯一真的有人写的 Wiki 子目录(`compile_wiki` 的
|
|
3597
|
+
// OUTPUT_ROOT)。这里曾经还列着 `Wiki/Entities` 与 `Wiki/Topics`——没有任何
|
|
3598
|
+
// 代码写它们,README 里那张表却把它们当成已有的介绍给用户。**声明了就要有东西
|
|
3599
|
+
// 往里写**,否则就是又一个"永远空着的目录"(同 `007_Wiki`)。
|
|
3600
|
+
// 两个知识库各一个目录:`Wiki/Concepts` 是公众号文章线的,`Chat/Concepts` 是聊天线的
|
|
3185
3601
|
'Wiki/Concepts',
|
|
3186
|
-
'
|
|
3187
|
-
'Wiki/Topics',
|
|
3602
|
+
'Chat/Concepts',
|
|
3188
3603
|
]
|
|
3189
3604
|
const files = [
|
|
3190
3605
|
{
|
|
@@ -3192,7 +3607,10 @@ program
|
|
|
3192
3607
|
content: JSON.stringify({
|
|
3193
3608
|
newFileLocation: 'folder',
|
|
3194
3609
|
newFileFolderPath: 'Sources',
|
|
3195
|
-
|
|
3610
|
+
// **`_attachments`,不是 `Assets`**:布局里建的就是 `_attachments`(这个
|
|
3611
|
+
// 字面量以前只在这里出现一次,是个孤例),而 init 从不建 `Assets`——
|
|
3612
|
+
// 于是附件会被 Obsidian 放进一个不存在的目录。
|
|
3613
|
+
attachmentFolderPath: '_attachments',
|
|
3196
3614
|
showInlineTitle: false,
|
|
3197
3615
|
}, null, 2),
|
|
3198
3616
|
},
|
|
@@ -3238,11 +3656,20 @@ program
|
|
|
3238
3656
|
'',
|
|
3239
3657
|
'| 目录 | 说明 |',
|
|
3240
3658
|
'|------|------|',
|
|
3241
|
-
'| `Sources/WeChat
|
|
3242
|
-
'| `
|
|
3243
|
-
'| `
|
|
3244
|
-
|
|
3659
|
+
'| `Sources/WeChat/<日期>/` | 原始文章(`daily` 与回填写入,连同正文图片) |',
|
|
3660
|
+
'| `001_Daily/<日期>.md` | 每天一页日记 |',
|
|
3661
|
+
'| `002_Literature/WeChat/<日期>/` | 阅读笔记(由 `Sources` 生成) |',
|
|
3662
|
+
// **两个知识库各写一行**:文章线(`Wiki/`)与聊天线(`Chat/`)是分开的两套,
|
|
3663
|
+
// 只列文章那套的话,用户在库里看到的说明与他实际拥有的东西对不上。
|
|
3664
|
+
'| `Wiki/Concepts/`、`Wiki/00-Overview.md` | **文章知识库**的概念页与索引(`wiki compile` 生成) |',
|
|
3665
|
+
'| `Chat/Concepts/`、`Chat/00-Overview.md` | **聊天知识库**的概念页与索引(`wiki compile --source output/chat-notes` 生成) |',
|
|
3666
|
+
'| `Sources/Chat/会话-<名字>.md` | 每个会话一张知识卡(`chat` 生成:聊了什么、有哪些人、欠着什么) |',
|
|
3245
3667
|
'| `Templates/` | 模板文件 |',
|
|
3668
|
+
'| `_attachments/` | Obsidian 附件(`.obsidian/app.json` 的 `attachmentFolderPath` 指向这里) |',
|
|
3669
|
+
'| `000_Inbox` `003_Ideas` `004_Permanent` `005_Reference` `006_Projects` `008_MOC` `999_Archive` | **给你自己写笔记用的层**:`vault promote` 才会往里放东西,空着是设计而不是没做完 |',
|
|
3670
|
+
'',
|
|
3671
|
+
'> 阅读笔记与日记里各有一段 `dataview` 查询,所以这个 Vault 需要 **Dataview 社区插件**',
|
|
3672
|
+
'> (社区插件不进仓库,换机器要自己装一次)。',
|
|
3246
3673
|
'',
|
|
3247
3674
|
'## 快速查询',
|
|
3248
3675
|
'',
|
|
@@ -3359,9 +3786,13 @@ program
|
|
|
3359
3786
|
new Command('compile')
|
|
3360
3787
|
.description('扫描文章 [[Wikilinks]] 聚合生成概念页')
|
|
3361
3788
|
.option('-l, --limit <n>', '最多生成概念数', '20')
|
|
3362
|
-
.option('--
|
|
3789
|
+
.option('--min-refs <n>', '至少被几篇材料提到才建页(2 能滤掉新闻里的一次性实体)', '1')
|
|
3790
|
+
.option('--source <dir>', '材料目录(**必须含 [[概念]] — 说明 形状的链接**,否则聚合不出任何概念)', './output/biz-daily')
|
|
3363
3791
|
.option('-o, --output <dir>', '概念页输出目录', './output/wechat-vault/Wiki/Concepts')
|
|
3364
3792
|
.option('--api-key <key>', 'DeepSeek API key')
|
|
3793
|
+
.option('--refresh-sources',
|
|
3794
|
+
'只把已有概念页的"来源"段补齐成当前全部来源(本地重写,**不调用模型**)')
|
|
3795
|
+
.option('--cards <dir...>', '产卡目录,可写多个;默认所有 output/*-notes(与索引的引用数同一口径)')
|
|
3365
3796
|
.option('--dry-run', '仅预览,不读取文章、调用 AI 或写入概念页')
|
|
3366
3797
|
.option('--yes', '确认调用 AI 并生成概念页')
|
|
3367
3798
|
.option('--json', '输出机器可读结果,不返回概念或本地路径')
|
|
@@ -3369,13 +3800,32 @@ program
|
|
|
3369
3800
|
const pkgRoot = resolvePackageRoot()
|
|
3370
3801
|
const script = join(pkgRoot, 'scripts', 'compile_wiki.py')
|
|
3371
3802
|
|
|
3803
|
+
if (opts.refreshSources) {
|
|
3804
|
+
// 本地补来源:**不调模型**,所以不传 apiKey、preview 里也不说"调用 AI"。
|
|
3805
|
+
// 但它会把已有页重写一遍(实测会动 358 张),照样走"先预览、再确认"那两步。
|
|
3806
|
+
await runConfirmedPythonMutation({
|
|
3807
|
+
action: 'wiki.refreshSources',
|
|
3808
|
+
script,
|
|
3809
|
+
args: ['--refresh-sources', '--output', opts.output,
|
|
3810
|
+
...(opts.cards ?? []).flatMap((c: string) => ['--cards', c])],
|
|
3811
|
+
cliOptions: opts,
|
|
3812
|
+
preview: { readsLocalCards: true, rewritesExistingPages: true, usesAi: false },
|
|
3813
|
+
confirmationMessage: `确认把 ${opts.output} 里已有概念页的"来源"段补齐?(本地重写,不调用 AI)`,
|
|
3814
|
+
apiKey: opts.apiKey,
|
|
3815
|
+
timeout: 300_000,
|
|
3816
|
+
})
|
|
3817
|
+
return
|
|
3818
|
+
}
|
|
3819
|
+
|
|
3372
3820
|
const limit = parseCliInteger(opts.limit, 'limit', 1, 1000, opts.json)
|
|
3821
|
+
const minRefs = parseCliInteger(opts.minRefs ?? '1', 'min-refs', 1, 50, opts.json)
|
|
3373
3822
|
await runConfirmedPythonMutation({
|
|
3374
3823
|
action: 'wiki.compile',
|
|
3375
3824
|
script,
|
|
3376
|
-
args: ['--limit', String(limit), '--source', opts.source, '--output', opts.output
|
|
3825
|
+
args: ['--limit', String(limit), '--source', opts.source, '--output', opts.output,
|
|
3826
|
+
'--min-refs', String(minRefs)],
|
|
3377
3827
|
cliOptions: opts,
|
|
3378
|
-
preview: { limit, readsLocalArticles: true, usesAi: true, writesConceptPages: true },
|
|
3828
|
+
preview: { limit, minRefs, readsLocalArticles: true, usesAi: true, writesConceptPages: true },
|
|
3379
3829
|
confirmationMessage: `确认调用 AI 并生成最多 ${limit} 个概念页?`,
|
|
3380
3830
|
apiKey: opts.apiKey,
|
|
3381
3831
|
timeout: 300_000,
|
|
@@ -3402,6 +3852,7 @@ program
|
|
|
3402
3852
|
.option('--skip-html', '跳过 HTML 阅读器生成')
|
|
3403
3853
|
.option('--skip-ai-report', '跳过 AI 深度阅读报告')
|
|
3404
3854
|
.option('--no-ai', '关闭全部 AI 调用,保留抓取和本地输出')
|
|
3855
|
+
.option('--no-summary', '只判断不生成:biz_daily 不调 LLM 写摘要/标签/简报(判断仍走 Jev)')
|
|
3405
3856
|
.option('--ai-report-range <n>', 'AI 报告覆盖最近 N 天', '1')
|
|
3406
3857
|
.option('--dry-run', '仅预览步骤,不读取聊天数据、调用网络或写入文件')
|
|
3407
3858
|
.option('--yes', '确认运行流水线')
|
|
@@ -3484,6 +3935,7 @@ program
|
|
|
3484
3935
|
if (opts.skipHtml) args.push('--skip-html')
|
|
3485
3936
|
if (opts.skipAiReport) args.push('--skip-ai-report')
|
|
3486
3937
|
if (noAi) args.push('--no-ai')
|
|
3938
|
+
if (opts.noSummary && !noAi) args.push('--no-summary')
|
|
3487
3939
|
|
|
3488
3940
|
try {
|
|
3489
3941
|
if (!opts.json) console.log(chalk.cyan('\n启动端到端流水线...\n'))
|
|
@@ -4401,22 +4853,35 @@ program
|
|
|
4401
4853
|
.description('构建语义搜索索引')
|
|
4402
4854
|
.option('--full', '全量重建')
|
|
4403
4855
|
.option('--api-key <key>', 'DashScope embedding API key')
|
|
4856
|
+
.option('--days <n>', '聊天记录收最近多少天(默认 90)')
|
|
4857
|
+
.option('--article-days <n>', '日报文章收最近多少个日期目录(默认 30)')
|
|
4404
4858
|
.option('--dry-run', '仅预览,不读取数据库、调用网络或写入索引')
|
|
4405
4859
|
.option('--yes', '确认构建或重建索引')
|
|
4406
4860
|
.option('--json', '输出 JSON 格式')
|
|
4407
4861
|
.action(async (opts) => {
|
|
4862
|
+
// 窗口的**运行值由这里说了算**:默认值 + 校验 + 透传都在这一处,预览报的就是真跑的那些
|
|
4863
|
+
// (脚本文档里另有自己的默认值,只供手动直接跑脚本时用;CLI 这条路永远显式传参,
|
|
4864
|
+
// 所以两边万一漂了也不会影响实际行为)。
|
|
4865
|
+
// 上限 36500 天≈100 年:不设上限的后果是"给个 1e9 就把遍历变成一次全库穿透"。
|
|
4866
|
+
const chatDays = opts.days === undefined ? 90 : parseCliInteger(opts.days, 'days', 1, 36_500, opts.json)
|
|
4867
|
+
const articleDays = opts.articleDays === undefined
|
|
4868
|
+
? 30 : parseCliInteger(opts.articleDays, 'article-days', 1, 36_500, opts.json)
|
|
4869
|
+
const windowText = `聊天最近 ${chatDays} 天、日报最近 ${articleDays} 个日期目录`
|
|
4408
4870
|
const preview = {
|
|
4409
4871
|
success: true,
|
|
4410
4872
|
dryRun: true,
|
|
4411
4873
|
action: 'search-index.build',
|
|
4412
4874
|
mode: opts.full ? 'full' : 'incremental',
|
|
4875
|
+
chatDays,
|
|
4876
|
+
articleDays,
|
|
4413
4877
|
readsLocalData: true,
|
|
4414
4878
|
usesCloudEmbedding: true,
|
|
4415
4879
|
replacesExistingIndex: !!opts.full,
|
|
4416
4880
|
}
|
|
4417
4881
|
if (opts.dryRun) {
|
|
4418
4882
|
if (opts.json) console.log(JSON.stringify(preview))
|
|
4419
|
-
else console.log(chalk.cyan(`语义索引预览:${opts.full ? '全量重建' : '增量构建'}
|
|
4883
|
+
else console.log(chalk.cyan(`语义索引预览:${opts.full ? '全量重建' : '增量构建'};`
|
|
4884
|
+
+ `窗口=${windowText}(要改:--days / --article-days),将读取本地数据并调用向量服务`))
|
|
4420
4885
|
return
|
|
4421
4886
|
}
|
|
4422
4887
|
if (!opts.yes) {
|
|
@@ -4427,7 +4892,10 @@ program
|
|
|
4427
4892
|
const { confirmed } = await inquirer.prompt([{
|
|
4428
4893
|
type: 'confirm',
|
|
4429
4894
|
name: 'confirmed',
|
|
4430
|
-
|
|
4895
|
+
// 窗口是**代价的一部分**:天数越多,发给向量服务的文本越多。所以确认语里要写出来,
|
|
4896
|
+
// 而不是只说"本地文本将发送"。
|
|
4897
|
+
message: `确认${opts.full ? '全量重建' : '增量构建'}语义索引(${windowText})?`
|
|
4898
|
+
+ '这些文本将发送到已配置的向量服务。',
|
|
4431
4899
|
default: false,
|
|
4432
4900
|
}])
|
|
4433
4901
|
if (!confirmed) {
|
|
@@ -4440,7 +4908,8 @@ program
|
|
|
4440
4908
|
const execFileAsync = promisify(execFile)
|
|
4441
4909
|
const pkgRoot = resolvePackageRoot()
|
|
4442
4910
|
const script = join(pkgRoot, 'scripts', 'semantic_search.py')
|
|
4443
|
-
const args: string[] = [script, 'build'
|
|
4911
|
+
const args: string[] = [script, 'build', '--days', String(chatDays),
|
|
4912
|
+
'--article-days', String(articleDays)]
|
|
4444
4913
|
if (opts.full) args.push('--full')
|
|
4445
4914
|
try {
|
|
4446
4915
|
const { stdout } = await execFileAsync(getPythonCommand(), args, {
|
|
@@ -4631,6 +5100,526 @@ program
|
|
|
4631
5100
|
}
|
|
4632
5101
|
})
|
|
4633
5102
|
|
|
5103
|
+
// decide —— 本机判断层。一个 state + 一批类型化问题,一次调用。
|
|
5104
|
+
// state 由调用方给,命令自己**不读任何本地数据**;但它是出境调用,所以照 awaiting/search
|
|
5105
|
+
// 的规矩:--dry-run 只校验、--yes 才真跑。这一版**不暴露成 MCP 工具**——那会变成
|
|
5106
|
+
// 远端 MCP 客户端能驱动本机往第三方发任意文本,属于要单独决策的出境面。
|
|
5107
|
+
program
|
|
5108
|
+
.command('decide')
|
|
5109
|
+
.description('本机判断层:一个 state + 一批类型化问题,一次调用返回带概率的答案')
|
|
5110
|
+
.option('--request <file>', '请求 JSON 的路径(含 state 与 questions)')
|
|
5111
|
+
.option('--over <glob>', '批量模式:对匹配到的每个文件问 --ask 里的每个问题(可重复)',
|
|
5112
|
+
(value: string, previous: string[]) => previous.concat([value]), [] as string[])
|
|
5113
|
+
.option('--ask <text>', '批量模式下的一个是非题,逐文件展开(可重复)',
|
|
5114
|
+
(value: string, previous: string[]) => previous.concat([value]), [] as string[])
|
|
5115
|
+
.option('--max-chars <n>', '批量模式下每个文件喂多少字符(默认 1500;它决定上限)', '1500')
|
|
5116
|
+
.option('--limit <n>', '批量模式最多取多少个文件(默认 50)', '50')
|
|
5117
|
+
.option('--model <name>', '模型别名,默认 jev-latest')
|
|
5118
|
+
.option('--dry-run', '只校验请求并回显形状,不调用决策模型')
|
|
5119
|
+
.option('--yes', '确认把 state 发送到已配置的决策模型服务')
|
|
5120
|
+
.option('--json', '输出机器可读结果(本命令的输出本来就是 JSON)')
|
|
5121
|
+
.action(async (opts) => {
|
|
5122
|
+
const { execFile } = await import('child_process')
|
|
5123
|
+
const { promisify } = await import('util')
|
|
5124
|
+
const { readFileSync } = await import('fs')
|
|
5125
|
+
const execFileAsync = promisify(execFile)
|
|
5126
|
+
const pkgRoot = resolvePackageRoot()
|
|
5127
|
+
const decideScript = join(pkgRoot, 'scripts', 'decide.py')
|
|
5128
|
+
|
|
5129
|
+
// 预览里要报"这批几个问题",所以在这里读一眼;**校验仍然只由脚本做**,
|
|
5130
|
+
// 免得两处校验逻辑各说各话。
|
|
5131
|
+
// 两种输入方式:--request 一个文件、或 --over 批量展开。脚本自己会拒掉
|
|
5132
|
+
// 两个都没给的情况,所以这里不做重复校验。
|
|
5133
|
+
const batch = Array.isArray(opts.over) && opts.over.length > 0
|
|
5134
|
+
let questionCount: number | null = null
|
|
5135
|
+
if (batch) {
|
|
5136
|
+
questionCount = opts.over.length * opts.ask.length || null
|
|
5137
|
+
} else if (opts.request) {
|
|
5138
|
+
try {
|
|
5139
|
+
const parsed = JSON.parse(readFileSync(opts.request, 'utf8'))
|
|
5140
|
+
if (parsed && typeof parsed === 'object' && parsed.questions
|
|
5141
|
+
&& typeof parsed.questions === 'object') {
|
|
5142
|
+
questionCount = Object.keys(parsed.questions).length
|
|
5143
|
+
}
|
|
5144
|
+
} catch {
|
|
5145
|
+
// 读不到或不是 JSON:交给脚本去报一个准确得多的错误。
|
|
5146
|
+
}
|
|
5147
|
+
}
|
|
5148
|
+
const limit = parseCliInteger(opts.limit, 'limit', 1, 500, opts.json)
|
|
5149
|
+
const maxChars = parseCliInteger(opts.maxChars, 'max-chars', 1, 100000, opts.json)
|
|
5150
|
+
|
|
5151
|
+
const args = [decideScript,
|
|
5152
|
+
...(batch
|
|
5153
|
+
? [...opts.over.flatMap((g: string) => ['--over', g]),
|
|
5154
|
+
...opts.ask.flatMap((a: string) => ['--ask', a]),
|
|
5155
|
+
'--max-chars', String(maxChars), '--limit', String(limit)]
|
|
5156
|
+
: ['--request', opts.request]),
|
|
5157
|
+
...(opts.model ? ['--model', opts.model] : [])]
|
|
5158
|
+
if (opts.dryRun) {
|
|
5159
|
+
args.push('--dry-run')
|
|
5160
|
+
} else if (!opts.yes) {
|
|
5161
|
+
const preview = {
|
|
5162
|
+
success: false, dryRun: false, action: 'decide',
|
|
5163
|
+
code: 'CONFIRMATION_REQUIRED',
|
|
5164
|
+
questionCount, readsLocalData: batch, invokesAI: true,
|
|
5165
|
+
}
|
|
5166
|
+
if (opts.json) {
|
|
5167
|
+
console.log(JSON.stringify(preview))
|
|
5168
|
+
process.exit(1)
|
|
5169
|
+
}
|
|
5170
|
+
const { confirmed } = await inquirer.prompt([{
|
|
5171
|
+
type: 'confirm',
|
|
5172
|
+
name: 'confirmed',
|
|
5173
|
+
message: `确认把请求里的 state 发送到已配置的决策模型服务吗?`
|
|
5174
|
+
+ (questionCount === null ? '' : `(${questionCount} 个问题)`),
|
|
5175
|
+
default: false,
|
|
5176
|
+
}])
|
|
5177
|
+
if (!confirmed) {
|
|
5178
|
+
console.log(chalk.gray('已取消'))
|
|
5179
|
+
return
|
|
5180
|
+
}
|
|
5181
|
+
}
|
|
5182
|
+
|
|
5183
|
+
try {
|
|
5184
|
+
const { stdout } = await execFileAsync(getPythonCommand(), args, {
|
|
5185
|
+
timeout: 120_000, maxBuffer: 10 * 1024 * 1024,
|
|
5186
|
+
env: pythonProcessEnv(),
|
|
5187
|
+
})
|
|
5188
|
+
process.stdout.write(stdout)
|
|
5189
|
+
} catch (error) {
|
|
5190
|
+
const payload = (error as { stdout?: string }).stdout
|
|
5191
|
+
if (payload && payload.trim()) {
|
|
5192
|
+
// 脚本自己已经给出了结构化的失败,原样透传比自己编一个更有用。
|
|
5193
|
+
process.stdout.write(payload)
|
|
5194
|
+
} else {
|
|
5195
|
+
console.log(JSON.stringify({ success: false, code: 'DECIDE_FAILED',
|
|
5196
|
+
action: 'decide', error: safeSubprocessError(error) }))
|
|
5197
|
+
}
|
|
5198
|
+
process.exit(1)
|
|
5199
|
+
}
|
|
5200
|
+
})
|
|
5201
|
+
|
|
5202
|
+
// awaiting —— 谁在等我回话。读本地聊天并把正文发给决策模型,所以照 search 的规矩:
|
|
5203
|
+
// --dry-run 预览、--yes 才真跑。它**不写任何本地数据**,但仍是一次正文出境,
|
|
5204
|
+
// 而聊天正文比日报的文章正文敏感一档。
|
|
5205
|
+
program
|
|
5206
|
+
.command('awaiting')
|
|
5207
|
+
.description('谁在等我回话:逐会话判断有没有欠下的回复(读取聊天正文并调用决策模型)')
|
|
5208
|
+
.option('--days <n>', '只看最近多少天有动静的会话', '14')
|
|
5209
|
+
.option('--limit <n>', '最多判多少个会话', '40')
|
|
5210
|
+
.option('--min-prob <p>', 'waiting 概率低于此值不算欠账', '0.5')
|
|
5211
|
+
.option('--dry-run', '仅预览会判哪些会话、要发多少字符,不调用决策模型')
|
|
5212
|
+
.option('--yes', '确认把聊天正文发送到已配置的决策模型服务')
|
|
5213
|
+
.option('--json', '输出机器可读结果;执行仍需 --yes')
|
|
5214
|
+
.action(async (opts) => {
|
|
5215
|
+
const { execFile } = await import('child_process')
|
|
5216
|
+
const { promisify } = await import('util')
|
|
5217
|
+
const execFileAsync = promisify(execFile)
|
|
5218
|
+
const pkgRoot = resolvePackageRoot()
|
|
5219
|
+
const script = join(pkgRoot, 'scripts', 'reply_debt.py')
|
|
5220
|
+
const days = parseCliInteger(opts.days, 'days', 1, 3650, opts.json)
|
|
5221
|
+
const limit = parseCliInteger(opts.limit, 'limit', 1, 500, opts.json)
|
|
5222
|
+
const args = [script, '--days', String(days), '--limit', String(limit),
|
|
5223
|
+
'--min-prob', String(opts.minProb),
|
|
5224
|
+
...(opts.json ? ['--json'] : [])]
|
|
5225
|
+
|
|
5226
|
+
if (opts.dryRun) {
|
|
5227
|
+
// 交给脚本自己报数:它才知道要发多少字符。这一步只读本地,零出境。
|
|
5228
|
+
args.push('--dry-run')
|
|
5229
|
+
} else if (!opts.yes) {
|
|
5230
|
+
const preview = {
|
|
5231
|
+
success: false, dryRun: false, action: 'reply-debt.scan',
|
|
5232
|
+
code: 'CONFIRMATION_REQUIRED', days, limit,
|
|
5233
|
+
readsLocalChat: true, invokesAI: true, writesNothing: true,
|
|
5234
|
+
}
|
|
5235
|
+
if (opts.json) {
|
|
5236
|
+
console.log(JSON.stringify(preview))
|
|
5237
|
+
process.exit(1)
|
|
5238
|
+
}
|
|
5239
|
+
const { confirmed } = await inquirer.prompt([{
|
|
5240
|
+
type: 'confirm',
|
|
5241
|
+
name: 'confirmed',
|
|
5242
|
+
message: '确认把最近这些会话的聊天正文发送到已配置的决策模型服务吗?',
|
|
5243
|
+
default: false,
|
|
5244
|
+
}])
|
|
5245
|
+
if (!confirmed) {
|
|
5246
|
+
console.log(chalk.gray('已取消'))
|
|
5247
|
+
return
|
|
5248
|
+
}
|
|
5249
|
+
}
|
|
5250
|
+
|
|
5251
|
+
try {
|
|
5252
|
+
const { stdout } = await execFileAsync(getPythonCommand(), args, {
|
|
5253
|
+
timeout: 600_000, maxBuffer: 50 * 1024 * 1024,
|
|
5254
|
+
env: pythonProcessEnv(),
|
|
5255
|
+
})
|
|
5256
|
+
process.stdout.write(stdout)
|
|
5257
|
+
} catch (error) {
|
|
5258
|
+
if (opts.json) {
|
|
5259
|
+
console.log(JSON.stringify({ success: false, code: 'AWAITING_FAILED', action: 'reply-debt.scan', error: safeSubprocessError(error) }))
|
|
5260
|
+
} else {
|
|
5261
|
+
console.error(chalk.red(`\n✗ ${safeSubprocessError(error)}`))
|
|
5262
|
+
}
|
|
5263
|
+
process.exit(1)
|
|
5264
|
+
}
|
|
5265
|
+
})
|
|
5266
|
+
|
|
5267
|
+
// draft:判断在前、起草在后、**绝不发送**
|
|
5268
|
+
program
|
|
5269
|
+
.command('draft <talker>')
|
|
5270
|
+
.description('帮我起草回复:先判断(意图/风险/该不该给实质),再起草候选 —— **只产出文本,不发送**')
|
|
5271
|
+
.option('--count <n>', '要几条候选', '3')
|
|
5272
|
+
.option('--gate-commitment', '承诺未兑现时也拒绝起草(默认只在提示词里约束)')
|
|
5273
|
+
.option('--dry-run', '仅预览会发多少字符给哪两个模型,不调用')
|
|
5274
|
+
.option('--yes', '确认把这段对话发送到判断模型与生成模型')
|
|
5275
|
+
.option('--json', '输出机器可读结果;执行仍需 --yes')
|
|
5276
|
+
.action(async (talker: string, opts) => {
|
|
5277
|
+
const { execFile } = await import('child_process')
|
|
5278
|
+
const { promisify } = await import('util')
|
|
5279
|
+
const execFileAsync = promisify(execFile)
|
|
5280
|
+
const pkgRoot = resolvePackageRoot()
|
|
5281
|
+
const script = join(pkgRoot, 'scripts', 'draft_reply.py')
|
|
5282
|
+
const count = parseCliInteger(opts.count, 'count', 1, 5, opts.json)
|
|
5283
|
+
// **"确认过了"只用一个变量表示**(`--yes` 或交互回答"是"),参数在确认之后才拼。
|
|
5284
|
+
// 原来的写法是先拼参数、再问,于是**交互确认那条路是坏的**:答了"是",`args` 里
|
|
5285
|
+
// 却从没补上 `--yes`,脚本照样拒绝(用户看到的是"我确认了它却说没确认")。
|
|
5286
|
+
// 两者合成一个变量之后,这种漏传在结构上不可能再发生。
|
|
5287
|
+
let confirmed = !!opts.yes
|
|
5288
|
+
if (!opts.dryRun && !confirmed) {
|
|
5289
|
+
const preview = {
|
|
5290
|
+
success: false, dryRun: false, action: 'draft-reply',
|
|
5291
|
+
code: 'CONFIRMATION_REQUIRED', talker, count,
|
|
5292
|
+
readsLocalChat: true, invokesAI: true, writesNothing: true, sendsNothing: true,
|
|
5293
|
+
}
|
|
5294
|
+
if (opts.json) {
|
|
5295
|
+
console.log(JSON.stringify(preview))
|
|
5296
|
+
process.exit(1)
|
|
5297
|
+
}
|
|
5298
|
+
const answer = await inquirer.prompt([{
|
|
5299
|
+
type: 'confirm',
|
|
5300
|
+
name: 'confirmed',
|
|
5301
|
+
message: '确认把这段对话发送到判断模型与生成模型吗?(只会产出候选文本,不会替你发送)',
|
|
5302
|
+
default: false,
|
|
5303
|
+
}])
|
|
5304
|
+
confirmed = !!answer.confirmed
|
|
5305
|
+
if (!confirmed) {
|
|
5306
|
+
console.log(chalk.gray('已取消'))
|
|
5307
|
+
return
|
|
5308
|
+
}
|
|
5309
|
+
}
|
|
5310
|
+
const args = [script, '--talker', String(talker), '--count', String(count),
|
|
5311
|
+
// 脚本自己有第二道闸门(`--yes` 才真的把对话发出去),所以要透传
|
|
5312
|
+
...(confirmed ? ['--yes'] : []),
|
|
5313
|
+
...(opts.gateCommitment ? ['--gate-commitment'] : []),
|
|
5314
|
+
...(opts.json ? ['--json'] : []),
|
|
5315
|
+
// dry-run 交给脚本自己报数(它才知道这段对话有多少字符),这一步只读本地、零出境
|
|
5316
|
+
...(opts.dryRun ? ['--dry-run'] : [])]
|
|
5317
|
+
|
|
5318
|
+
try {
|
|
5319
|
+
const { stdout } = await execFileAsync(getPythonCommand(), args, {
|
|
5320
|
+
timeout: 300_000, maxBuffer: 50 * 1024 * 1024,
|
|
5321
|
+
env: pythonProcessEnv(),
|
|
5322
|
+
})
|
|
5323
|
+
process.stdout.write(stdout)
|
|
5324
|
+
} catch (error) {
|
|
5325
|
+
if (opts.json) {
|
|
5326
|
+
console.log(JSON.stringify({ success: false, code: 'DRAFT_FAILED', action: 'draft-reply', error: safeSubprocessError(error) }))
|
|
5327
|
+
} else {
|
|
5328
|
+
console.error(chalk.red(`\n✗ ${safeSubprocessError(error)}`))
|
|
5329
|
+
}
|
|
5330
|
+
process.exit(1)
|
|
5331
|
+
}
|
|
5332
|
+
})
|
|
5333
|
+
|
|
5334
|
+
// wiki lint:知识库体检(全本地:不联网、不调用模型)
|
|
5335
|
+
program.commands.find(c => c.name() === 'wiki')?.addCommand(
|
|
5336
|
+
new Command('lint')
|
|
5337
|
+
.description('知识库体检:断链、孤儿、空页、同名(本地,不联网)')
|
|
5338
|
+
.option('--dir <dir>', '概念页目录', './output/wechat-vault/Wiki/Concepts')
|
|
5339
|
+
.option('--json', '输出机器可读结果')
|
|
5340
|
+
.action(async (opts) => {
|
|
5341
|
+
const { execFile } = await import('child_process')
|
|
5342
|
+
const { promisify } = await import('util')
|
|
5343
|
+
const execFileAsync = promisify(execFile)
|
|
5344
|
+
const script = join(resolvePackageRoot(), 'scripts', 'wiki_lint.py')
|
|
5345
|
+
const args = [script, '--dir', String(opts.dir), ...(opts.json ? ['--json'] : [])]
|
|
5346
|
+
try {
|
|
5347
|
+
const { stdout } = await execFileAsync(getPythonCommand(), args, {
|
|
5348
|
+
timeout: 120_000, maxBuffer: 20 * 1024 * 1024, env: pythonProcessEnv(),
|
|
5349
|
+
})
|
|
5350
|
+
process.stdout.write(stdout)
|
|
5351
|
+
} catch (error) {
|
|
5352
|
+
if (opts.json) console.log(JSON.stringify({ success: false, code: 'WIKI_LINT_FAILED', error: safeSubprocessError(error) }))
|
|
5353
|
+
else console.error(chalk.red(`
|
|
5354
|
+
✗ ${safeSubprocessError(error)}`))
|
|
5355
|
+
process.exit(1)
|
|
5356
|
+
}
|
|
5357
|
+
}))
|
|
5358
|
+
|
|
5359
|
+
// chat-notes:把会话整理成知识卡,喂给 wiki compile 聚成概念页
|
|
5360
|
+
program
|
|
5361
|
+
.command('chat-notes')
|
|
5362
|
+
.description('把最近一段时间的会话整理成知识卡(之后用 wiki compile 聚成概念页)—— **只写本地文件,不发送**')
|
|
5363
|
+
.option('--days <n>', '看最近多少天', '30')
|
|
5364
|
+
.option('--limit <n>', '最多处理几个会话', '40')
|
|
5365
|
+
.option('--dry-run', '仅预览:几个会话、多少字符会发给生成模型(只读本地,零出境)')
|
|
5366
|
+
.option('--yes', '确认把对话发送给生成模型')
|
|
5367
|
+
.option('--json', '输出机器可读结果;执行仍需 --yes')
|
|
5368
|
+
.action(async (opts) => {
|
|
5369
|
+
const { execFile } = await import('child_process')
|
|
5370
|
+
const { promisify } = await import('util')
|
|
5371
|
+
const execFileAsync = promisify(execFile)
|
|
5372
|
+
const pkgRoot = resolvePackageRoot()
|
|
5373
|
+
const script = join(pkgRoot, 'scripts', 'chat_notes.py')
|
|
5374
|
+
const days = parseCliInteger(opts.days, 'days', 1, 3650, opts.json)
|
|
5375
|
+
const limit = parseCliInteger(opts.limit, 'limit', 1, 200, opts.json)
|
|
5376
|
+
// 与 `draft` 同一条纪律:**"确认过了"只用一个变量**,参数在确认之后才拼
|
|
5377
|
+
let confirmed = !!opts.yes
|
|
5378
|
+
if (!opts.dryRun && !confirmed) {
|
|
5379
|
+
const preview = {
|
|
5380
|
+
success: false, dryRun: false, action: 'chat-notes',
|
|
5381
|
+
code: 'CONFIRMATION_REQUIRED', days, limit,
|
|
5382
|
+
readsLocalChat: true, invokesAI: true, writesLocalFiles: true, sendsNothing: true,
|
|
5383
|
+
}
|
|
5384
|
+
if (opts.json) {
|
|
5385
|
+
console.log(JSON.stringify(preview))
|
|
5386
|
+
process.exit(1)
|
|
5387
|
+
}
|
|
5388
|
+
const answer = await inquirer.prompt([{
|
|
5389
|
+
type: 'confirm',
|
|
5390
|
+
name: 'confirmed',
|
|
5391
|
+
message: '确认把最近这些会话的对话发送给生成模型吗?(只会产出本地知识卡,不会替你发送任何消息)',
|
|
5392
|
+
default: false,
|
|
5393
|
+
}])
|
|
5394
|
+
confirmed = !!answer.confirmed
|
|
5395
|
+
if (!confirmed) {
|
|
5396
|
+
console.log(chalk.gray('已取消'))
|
|
5397
|
+
return
|
|
5398
|
+
}
|
|
5399
|
+
}
|
|
5400
|
+
const args = [script, '--days', String(days), '--limit', String(limit),
|
|
5401
|
+
...(confirmed ? ['--yes'] : []),
|
|
5402
|
+
...(opts.json ? ['--json'] : []),
|
|
5403
|
+
...(opts.dryRun ? ['--dry-run'] : [])]
|
|
5404
|
+
try {
|
|
5405
|
+
const { stdout } = await execFileAsync(getPythonCommand(), args, {
|
|
5406
|
+
timeout: 900_000, maxBuffer: 50 * 1024 * 1024,
|
|
5407
|
+
env: pythonProcessEnv(),
|
|
5408
|
+
})
|
|
5409
|
+
process.stdout.write(stdout)
|
|
5410
|
+
} catch (error) {
|
|
5411
|
+
if (opts.json) {
|
|
5412
|
+
console.log(JSON.stringify({ success: false, code: 'CHAT_NOTES_FAILED', action: 'chat-notes', error: safeSubprocessError(error) }))
|
|
5413
|
+
} else {
|
|
5414
|
+
console.error(chalk.red(`\n✗ ${safeSubprocessError(error)}`))
|
|
5415
|
+
}
|
|
5416
|
+
process.exit(1)
|
|
5417
|
+
}
|
|
5418
|
+
})
|
|
5419
|
+
|
|
5420
|
+
// user-notes:读你自己写的笔记(第四条来源,也是唯一读"人写的东西"的一条)
|
|
5421
|
+
program
|
|
5422
|
+
.command('user-notes')
|
|
5423
|
+
.description('读你自己写的笔记,长成知识卡(之后用 wiki compile 聚成概念页)—— **你的笔记只读,绝不修改**')
|
|
5424
|
+
.option('--limit <n>', '最多处理几篇笔记', '20')
|
|
5425
|
+
.option('--vault <dir>', '库目录', './output/wechat-vault')
|
|
5426
|
+
.option('--refresh', '已有卡的也重做')
|
|
5427
|
+
.option('--dry-run', '仅预览:几篇笔记会发给模型(只读本地,零出境)')
|
|
5428
|
+
.option('--yes', '确认把笔记发给生成模型')
|
|
5429
|
+
.option('--json', '输出机器可读结果;执行仍需 --yes')
|
|
5430
|
+
.action(async (opts) => {
|
|
5431
|
+
const { execFile } = await import('child_process')
|
|
5432
|
+
const { promisify } = await import('util')
|
|
5433
|
+
const execFileAsync = promisify(execFile)
|
|
5434
|
+
const script = join(resolvePackageRoot(), 'scripts', 'user_notes.py')
|
|
5435
|
+
const limit = parseCliInteger(opts.limit, 'limit', 1, 500, opts.json)
|
|
5436
|
+
let confirmed = !!opts.yes
|
|
5437
|
+
if (!opts.dryRun && !confirmed) {
|
|
5438
|
+
const preview = {
|
|
5439
|
+
success: false, dryRun: false, action: 'user-notes',
|
|
5440
|
+
code: 'CONFIRMATION_REQUIRED', limit,
|
|
5441
|
+
readsLocalData: true, invokesAI: true, writesLocalFiles: true,
|
|
5442
|
+
modifiesYourNotes: false, sendsNothing: true,
|
|
5443
|
+
}
|
|
5444
|
+
if (opts.json) {
|
|
5445
|
+
console.log(JSON.stringify(preview))
|
|
5446
|
+
process.exit(1)
|
|
5447
|
+
}
|
|
5448
|
+
const answer = await inquirer.prompt([{
|
|
5449
|
+
type: 'confirm',
|
|
5450
|
+
name: 'confirmed',
|
|
5451
|
+
message: '确认把你自己写的这些笔记发给生成模型吗?(你的笔记只读,产出的卡另存;只写本地文件)',
|
|
5452
|
+
default: false,
|
|
5453
|
+
}])
|
|
5454
|
+
confirmed = !!answer.confirmed
|
|
5455
|
+
if (!confirmed) {
|
|
5456
|
+
console.log(chalk.gray('已取消'))
|
|
5457
|
+
return
|
|
5458
|
+
}
|
|
5459
|
+
}
|
|
5460
|
+
const args = [script, '--limit', String(limit), '--vault', String(opts.vault),
|
|
5461
|
+
...(opts.refresh ? ['--refresh'] : []),
|
|
5462
|
+
...(confirmed ? ['--yes'] : []),
|
|
5463
|
+
...(opts.json ? ['--json'] : []),
|
|
5464
|
+
...(opts.dryRun ? ['--dry-run'] : [])]
|
|
5465
|
+
try {
|
|
5466
|
+
const { stdout } = await execFileAsync(getPythonCommand(), args, {
|
|
5467
|
+
timeout: 1_800_000, maxBuffer: 50 * 1024 * 1024,
|
|
5468
|
+
env: pythonProcessEnv(),
|
|
5469
|
+
})
|
|
5470
|
+
process.stdout.write(stdout)
|
|
5471
|
+
} catch (error) {
|
|
5472
|
+
if (opts.json) {
|
|
5473
|
+
console.log(JSON.stringify({ success: false, code: 'USER_NOTES_FAILED', action: 'user-notes',
|
|
5474
|
+
error: safeSubprocessError(error), detail: String((error as any)?.stdout || '').slice(0, 2000) }))
|
|
5475
|
+
} else {
|
|
5476
|
+
console.error(chalk.red(`\n✗ ${safeSubprocessError(error)}`))
|
|
5477
|
+
}
|
|
5478
|
+
process.exit(1)
|
|
5479
|
+
}
|
|
5480
|
+
})
|
|
5481
|
+
|
|
5482
|
+
// fav-notes:把微信收藏里的文章变成知识卡(第三条来源)
|
|
5483
|
+
program
|
|
5484
|
+
.command('fav-notes')
|
|
5485
|
+
.description('把微信收藏里的文章整理成知识卡(之后用 wiki compile 聚成概念页)—— **只写本地文件,不发送**')
|
|
5486
|
+
.option('--limit <n>', '最多处理几条收藏(按收藏时间倒序)', '10')
|
|
5487
|
+
.option('--refresh', '已有卡的也重做(默认增量:只做还没卡的)')
|
|
5488
|
+
.option('--dry-run', '仅预览:几条、多少要联网抓(只读本地,零出境)')
|
|
5489
|
+
.option('--yes', '确认把收藏的正文发给生成模型')
|
|
5490
|
+
.option('--json', '输出机器可读结果;执行仍需 --yes')
|
|
5491
|
+
.action(async (opts) => {
|
|
5492
|
+
const { execFile } = await import('child_process')
|
|
5493
|
+
const { promisify } = await import('util')
|
|
5494
|
+
const execFileAsync = promisify(execFile)
|
|
5495
|
+
const script = join(resolvePackageRoot(), 'scripts', 'fav_notes.py')
|
|
5496
|
+
const limit = parseCliInteger(opts.limit, 'limit', 1, 500, opts.json)
|
|
5497
|
+
// 与另外两条线同一条纪律:**「确认过了」只用一个变量**,参数在确认之后才拼
|
|
5498
|
+
let confirmed = !!opts.yes
|
|
5499
|
+
if (!opts.dryRun && !confirmed) {
|
|
5500
|
+
const preview = {
|
|
5501
|
+
success: false, dryRun: false, action: 'fav-notes',
|
|
5502
|
+
code: 'CONFIRMATION_REQUIRED', limit,
|
|
5503
|
+
readsLocalData: true, fetchesArticleUrls: true, invokesAI: true,
|
|
5504
|
+
writesLocalFiles: true, sendsNothing: true,
|
|
5505
|
+
}
|
|
5506
|
+
if (opts.json) {
|
|
5507
|
+
console.log(JSON.stringify(preview))
|
|
5508
|
+
process.exit(1)
|
|
5509
|
+
}
|
|
5510
|
+
const answer = await inquirer.prompt([{
|
|
5511
|
+
type: 'confirm',
|
|
5512
|
+
name: 'confirmed',
|
|
5513
|
+
message: '确认把这些收藏的正文发给生成模型吗?(只写本地知识卡;正文取不到时会去抓文章链接)',
|
|
5514
|
+
default: false,
|
|
5515
|
+
}])
|
|
5516
|
+
confirmed = !!answer.confirmed
|
|
5517
|
+
if (!confirmed) {
|
|
5518
|
+
console.log(chalk.gray('已取消'))
|
|
5519
|
+
return
|
|
5520
|
+
}
|
|
5521
|
+
}
|
|
5522
|
+
const args = [script, '--limit', String(limit),
|
|
5523
|
+
...(opts.refresh ? ['--refresh'] : []),
|
|
5524
|
+
...(confirmed ? ['--yes'] : []),
|
|
5525
|
+
...(opts.json ? ['--json'] : []),
|
|
5526
|
+
...(opts.dryRun ? ['--dry-run'] : [])]
|
|
5527
|
+
try {
|
|
5528
|
+
const { stdout } = await execFileAsync(getPythonCommand(), args, {
|
|
5529
|
+
timeout: 1_800_000, maxBuffer: 50 * 1024 * 1024,
|
|
5530
|
+
env: pythonProcessEnv(),
|
|
5531
|
+
})
|
|
5532
|
+
process.stdout.write(stdout)
|
|
5533
|
+
} catch (error) {
|
|
5534
|
+
if (opts.json) {
|
|
5535
|
+
console.log(JSON.stringify({ success: false, code: 'FAV_NOTES_FAILED', action: 'fav-notes',
|
|
5536
|
+
error: safeSubprocessError(error), detail: String((error as any)?.stdout || '').slice(0, 2000) }))
|
|
5537
|
+
} else {
|
|
5538
|
+
console.error(chalk.red(`\n✗ ${safeSubprocessError(error)}`))
|
|
5539
|
+
}
|
|
5540
|
+
process.exit(1)
|
|
5541
|
+
}
|
|
5542
|
+
})
|
|
5543
|
+
|
|
5544
|
+
// article-notes:从文章笔记里提炼概念,喂给 wiki compile
|
|
5545
|
+
program
|
|
5546
|
+
.command('article-notes')
|
|
5547
|
+
.description('从 Vault 的文章笔记里提炼概念(之后用 wiki compile 聚成概念页)—— **只写本地文件,不发送**')
|
|
5548
|
+
.option('--source <dir>', '文章笔记目录', './output/wechat-vault/002_Literature')
|
|
5549
|
+
.option('--limit <n>', '最多处理几篇(按发布时间倒序)', '30')
|
|
5550
|
+
.option('--since <date>', '只看这个日期之后的(YYYY-MM-DD)—— 比 --limit 精确,例:"9 月的都跑一遍"')
|
|
5551
|
+
.option('--until <date>', '只看这个日期之前的(YYYY-MM-DD)')
|
|
5552
|
+
.option('--topic <name>', '只做这些主题,可重复或用逗号分隔(如 --topic AI);不传=全部',
|
|
5553
|
+
(value, previous: string[] = []) => [...previous, value], [])
|
|
5554
|
+
.option('--workers <n>', '并发问几篇(只并行"问",写仍是串行)', '6')
|
|
5555
|
+
.option('--dry-run', '仅预览:几篇、多少字会发给生成模型(只读本地,零出境)')
|
|
5556
|
+
.option('--refresh-summaries', '只按原笔记重算已有卡片的摘要(本地,不调用模型、不必 --yes)')
|
|
5557
|
+
.option('--yes', '确认把文章发给生成模型')
|
|
5558
|
+
.option('--json', '输出机器可读结果;执行仍需 --yes')
|
|
5559
|
+
.action(async (opts) => {
|
|
5560
|
+
const { execFile } = await import('child_process')
|
|
5561
|
+
const { promisify } = await import('util')
|
|
5562
|
+
const execFileAsync = promisify(execFile)
|
|
5563
|
+
const pkgRoot = resolvePackageRoot()
|
|
5564
|
+
const script = join(pkgRoot, 'scripts', 'article_notes.py')
|
|
5565
|
+
const limit = parseCliInteger(opts.limit, 'limit', 1, 5000, opts.json)
|
|
5566
|
+
// 主题筛选:这一步是**纯花钱的**(一篇一次调用),而实测主题分布是新闻 57% /
|
|
5567
|
+
// AI 28% / 学术 13%,所以"只做 AI"是省掉一半以上花费的那个开关。它必须出现在
|
|
5568
|
+
// 确认框里——不然用户看到的是"要花 1.6 万次调用",而不是"要花 4 千次"。
|
|
5569
|
+
const topics = (opts.topic as string[])
|
|
5570
|
+
.flatMap(value => String(value).split(','))
|
|
5571
|
+
.map(value => value.trim())
|
|
5572
|
+
.filter(Boolean)
|
|
5573
|
+
const workers = parseCliInteger(opts.workers, 'workers', 1, 64, opts.json)
|
|
5574
|
+
// 与 `chat-notes`/`draft` 同一条纪律:**"确认过了"只用一个变量**,参数在确认之后才拼
|
|
5575
|
+
let confirmed = !!opts.yes
|
|
5576
|
+
if (!opts.dryRun && !opts.refreshSummaries && !confirmed) {
|
|
5577
|
+
const preview = {
|
|
5578
|
+
success: false, dryRun: false, action: 'article-notes',
|
|
5579
|
+
code: 'CONFIRMATION_REQUIRED', source: opts.source, limit, topics,
|
|
5580
|
+
readsLocalData: true, invokesAI: true, writesLocalFiles: true, sendsNothing: true,
|
|
5581
|
+
}
|
|
5582
|
+
if (opts.json) {
|
|
5583
|
+
console.log(JSON.stringify(preview))
|
|
5584
|
+
process.exit(1)
|
|
5585
|
+
}
|
|
5586
|
+
const answer = await inquirer.prompt([{
|
|
5587
|
+
type: 'confirm',
|
|
5588
|
+
name: 'confirmed',
|
|
5589
|
+
message: '确认把这些文章(只问概念,摘要照抄)发给生成模型吗?(只写本地知识卡,不会发送任何消息)',
|
|
5590
|
+
default: false,
|
|
5591
|
+
}])
|
|
5592
|
+
confirmed = !!answer.confirmed
|
|
5593
|
+
if (!confirmed) {
|
|
5594
|
+
console.log(chalk.gray('已取消'))
|
|
5595
|
+
return
|
|
5596
|
+
}
|
|
5597
|
+
}
|
|
5598
|
+
const args = [script, '--source', String(opts.source), '--limit', String(limit),
|
|
5599
|
+
...(opts.since ? ['--since', String(opts.since)] : []),
|
|
5600
|
+
...(opts.until ? ['--until', String(opts.until)] : []),
|
|
5601
|
+
...(topics.length ? ['--topic', topics.join(',')] : []),
|
|
5602
|
+
'--workers', String(workers),
|
|
5603
|
+
...(opts.refreshSummaries ? ['--refresh-summaries'] : []),
|
|
5604
|
+
...(confirmed ? ['--yes'] : []),
|
|
5605
|
+
...(opts.json ? ['--json'] : []),
|
|
5606
|
+
...(opts.dryRun ? ['--dry-run'] : [])]
|
|
5607
|
+
try {
|
|
5608
|
+
const { stdout } = await execFileAsync(getPythonCommand(), args, {
|
|
5609
|
+
timeout: 1_800_000, maxBuffer: 50 * 1024 * 1024,
|
|
5610
|
+
env: pythonProcessEnv(),
|
|
5611
|
+
})
|
|
5612
|
+
process.stdout.write(stdout)
|
|
5613
|
+
} catch (error) {
|
|
5614
|
+
if (opts.json) {
|
|
5615
|
+
console.log(JSON.stringify({ success: false, code: 'ARTICLE_NOTES_FAILED', action: 'article-notes', error: safeSubprocessError(error) }))
|
|
5616
|
+
} else {
|
|
5617
|
+
console.error(chalk.red(`\n✗ ${safeSubprocessError(error)}`))
|
|
5618
|
+
}
|
|
5619
|
+
process.exit(1)
|
|
5620
|
+
}
|
|
5621
|
+
})
|
|
5622
|
+
|
|
4634
5623
|
// todos
|
|
4635
5624
|
const todosCmd = program
|
|
4636
5625
|
.command('todos')
|
|
@@ -4752,6 +5741,7 @@ program
|
|
|
4752
5741
|
.option('--dry-run', '仅预览文章,不调用 AI 或写入日报')
|
|
4753
5742
|
.option('--yes', '确认执行日报生成;JSON 模式需要此选项')
|
|
4754
5743
|
.option('--no-ai', '关闭本次日报的所有 AI 处理')
|
|
5744
|
+
.option('--no-summary', '只判断不生成:不调 LLM 写摘要/标签/简报(主题与相关度仍由 Jev 判断),无需 DeepSeek key')
|
|
4755
5745
|
.option('--json', '输出机器可读的最终结果;运行日志写入 stderr')
|
|
4756
5746
|
.action(async (opts) => {
|
|
4757
5747
|
const { spawn } = await import('child_process')
|
|
@@ -4784,6 +5774,8 @@ program
|
|
|
4784
5774
|
const runPipeline = (targetDate: string): Promise<number> => new Promise((resolve) => {
|
|
4785
5775
|
const args = [pipeline, '--date', targetDate, '--engine', noAi ? 'local' : 'deepseek', '--interest', 'AI', '--skip-wiki']
|
|
4786
5776
|
if (noAi) args.push('--no-ai')
|
|
5777
|
+
// 与 --no-ai 不同:这一条保留判断(Jev),只关掉 LLM 的文字生成。
|
|
5778
|
+
if (opts.noSummary && !noAi) args.push('--no-summary')
|
|
4787
5779
|
for (const source of opts.source || []) args.push('--source', source)
|
|
4788
5780
|
if (opts.skipClassify) args.push('--skip-classify')
|
|
4789
5781
|
|
|
@@ -4988,7 +5980,7 @@ assistantCmd
|
|
|
4988
5980
|
process.exit(1)
|
|
4989
5981
|
}
|
|
4990
5982
|
const { startDaemon } = await import('../src/services/assistantDaemon.js')
|
|
4991
|
-
const r = startDaemon()
|
|
5983
|
+
const r = await startDaemon()
|
|
4992
5984
|
if (opts.json) {
|
|
4993
5985
|
console.log(JSON.stringify(r.started
|
|
4994
5986
|
? { success: true, started: true, pid: r.pid, ...preview }
|
|
@@ -5043,13 +6035,23 @@ assistantCmd
|
|
|
5043
6035
|
const token = configService.get('wechatOcToken')
|
|
5044
6036
|
const aiKey = configService.get('deepseekApiKey')
|
|
5045
6037
|
const { privacyGate } = await import('../src/services/assistantPrivacy.js')
|
|
6038
|
+
const { readEndpoint } = await import('../src/panel/endpoint.js')
|
|
5046
6039
|
const wl = String(configService.get('assistantWhitelist') || '').trim()
|
|
5047
6040
|
const groups = String(configService.get('assistantGroupWhitelist') || '').trim()
|
|
6041
|
+
// 端点是**运行态**的权威来源:它由守护进程原子写出,而且读取端会探活(进程没了就当没有)。
|
|
6042
|
+
// 它能回答一个配置回答不了的问题:**通道到底接上了没有**。
|
|
6043
|
+
// `messageChannelLoggedIn` 说的只是"配了 token 吗"——token 配了而进程以本机模式跑着,
|
|
6044
|
+
// 这两个字段会一个 true 一个 false,今天完全没有别的办法区分。
|
|
6045
|
+
const endpoint = alive ? readEndpoint() : null
|
|
5048
6046
|
if (opts.json) {
|
|
5049
6047
|
console.log(JSON.stringify({
|
|
5050
6048
|
success: true,
|
|
5051
6049
|
daemonRunning: alive,
|
|
5052
6050
|
messageChannelLoggedIn: !!token,
|
|
6051
|
+
channelActive: !!endpoint && endpoint.channel === 'wechat',
|
|
6052
|
+
mode: endpoint ? endpoint.channel : null,
|
|
6053
|
+
panelPort: endpoint ? endpoint.port : null,
|
|
6054
|
+
memoryBucket: endpoint ? endpoint.memoryBucket : null,
|
|
5053
6055
|
aiConfigured: privacyGate.isLocalInference() || !!aiKey,
|
|
5054
6056
|
localInference: privacyGate.isLocalInference(),
|
|
5055
6057
|
privacyMode: privacyGate.mode(),
|
|
@@ -5061,6 +6063,12 @@ assistantCmd
|
|
|
5061
6063
|
}
|
|
5062
6064
|
console.log(`守护进程: ${alive ? chalk.green(`运行中 (pid ${pid})`) : chalk.gray('未运行')}`)
|
|
5063
6065
|
console.log(`消息通道: ${token ? chalk.green('已登录') : chalk.red('未登录 (先 login-wechat)')}`)
|
|
6066
|
+
if (endpoint) {
|
|
6067
|
+
console.log(` └ 实际接入: ${endpoint.channel === 'wechat' ? chalk.green('微信') : chalk.yellow('仅本机入口')}`
|
|
6068
|
+
+ `|本机入口 http://127.0.0.1:${endpoint.port}|记忆桶 ${endpoint.memoryBucket}`)
|
|
6069
|
+
} else if (alive) {
|
|
6070
|
+
console.log(chalk.gray(' └ 本机入口未启动(看 assistant log 里的“本机入口启动失败”)'))
|
|
6071
|
+
}
|
|
5064
6072
|
console.log(`LLM 大脑: ${aiKey ? chalk.green('DeepSeek 已配置') : chalk.gray('未配置 (config set deepseekApiKey)')}`)
|
|
5065
6073
|
console.log(`隐私模式: ${chalk.cyan(privacyGate.mode())}${privacyGate.isLocalInference() ? chalk.green(' (本地推理, 数据不出境)') : chalk.gray(' (工具结果脱敏后出境)')}`)
|
|
5066
6074
|
console.log(`白名单: ${wl ? chalk.green(`${wl.split(/[,;\s]+/).filter(Boolean).length} 人`) : chalk.red('未设置 (默认拒绝所有人; config set assistantWhitelist "<@im.wechat ID>")')}`)
|
|
@@ -5094,6 +6102,30 @@ assistantCmd
|
|
|
5094
6102
|
console.log(content)
|
|
5095
6103
|
})
|
|
5096
6104
|
|
|
6105
|
+
assistantCmd
|
|
6106
|
+
.command('trace')
|
|
6107
|
+
.description('看助手最近几轮是怎么走的(判断层 → 工具 → 答复),含模型返回的推理内容')
|
|
6108
|
+
.option('-n, --last <number>', '看最近几轮', '3')
|
|
6109
|
+
.option('--json', '输出结构化轨迹')
|
|
6110
|
+
.action(async (opts) => {
|
|
6111
|
+
const { readTurns, describeTurn, traceFile } = await import('../src/services/assistantTrace.js')
|
|
6112
|
+
const count = parseCliInteger(opts.last, 'last', 1, 50, opts.json)
|
|
6113
|
+
const turns = readTurns(count)
|
|
6114
|
+
if (opts.json) {
|
|
6115
|
+
console.log(JSON.stringify({ success: true, file: traceFile(), count: turns.length, turns }))
|
|
6116
|
+
return
|
|
6117
|
+
}
|
|
6118
|
+
if (!turns.length) {
|
|
6119
|
+
console.log('还没有轨迹。助手收到消息之后才会有;文件:' + traceFile())
|
|
6120
|
+
return
|
|
6121
|
+
}
|
|
6122
|
+
console.log(traceFile())
|
|
6123
|
+
for (const turn of turns) {
|
|
6124
|
+
for (const line of describeTurn(turn)) console.log(line)
|
|
6125
|
+
console.log('')
|
|
6126
|
+
}
|
|
6127
|
+
})
|
|
6128
|
+
|
|
5097
6129
|
assistantCmd
|
|
5098
6130
|
.command('run')
|
|
5099
6131
|
.description('前台运行 (调试用; 常驻请用 start)')
|
|
@@ -5136,6 +6168,193 @@ assistantCmd
|
|
|
5136
6168
|
await assistant.start((line) => console.log(chalk.gray(line)))
|
|
5137
6169
|
})
|
|
5138
6170
|
|
|
6171
|
+
/**
|
|
6172
|
+
* 本机面板:不登录微信也能跟助手说话的入口。
|
|
6173
|
+
*
|
|
6174
|
+
* **它只是客户端**:消息走守护进程的回环端点,于是两个入口共用一个大脑
|
|
6175
|
+
* (同一份记忆、同一条配额、同一条串行队列)。面板自己起一个 `AssistantService` 会变成
|
|
6176
|
+
* 第二个大脑——那是这个功能最容易走错的一步。
|
|
6177
|
+
*/
|
|
6178
|
+
program
|
|
6179
|
+
.command('panel')
|
|
6180
|
+
.description('打开本机面板窗口 (Electron 悬浮球;没有 Electron 就降级用 Edge --app)')
|
|
6181
|
+
.option('--status', '只看状态,不打开窗口')
|
|
6182
|
+
.option('--ask <text>', '在命令行里问一句(面板减去像素)')
|
|
6183
|
+
.option('--dry-run', '仅预览,不启动窗口或守护进程')
|
|
6184
|
+
.option('--yes', '确认打开')
|
|
6185
|
+
.option('--json', '输出 JSON 格式')
|
|
6186
|
+
.action(async (opts) => {
|
|
6187
|
+
const { readEndpoint, endpointFile } = await import('../src/panel/endpoint.js')
|
|
6188
|
+
const { panelStatus, panelAsk, panelPair } = await import('../src/panel/client.js')
|
|
6189
|
+
|
|
6190
|
+
// ---------------------------------------------------------- 只读:状态
|
|
6191
|
+
if (opts.status) {
|
|
6192
|
+
const endpoint = readEndpoint()
|
|
6193
|
+
if (!endpoint) {
|
|
6194
|
+
if (opts.json) console.log(JSON.stringify({ success: false, code: 'PANEL_NOT_RUNNING', error: '本机入口没在运行(先 weflow-cli assistant start)' }))
|
|
6195
|
+
else console.log(chalk.gray('本机入口没在运行(先 weflow-cli assistant start)'))
|
|
6196
|
+
process.exit(1)
|
|
6197
|
+
}
|
|
6198
|
+
const r = await panelStatus(endpoint)
|
|
6199
|
+
if (opts.json) {
|
|
6200
|
+
console.log(JSON.stringify(r.ok
|
|
6201
|
+
? { success: true, port: endpoint.port, ...r.data }
|
|
6202
|
+
: { success: false, code: r.code }))
|
|
6203
|
+
return
|
|
6204
|
+
}
|
|
6205
|
+
if (!r.ok) { console.log(chalk.yellow(`读状态失败: ${r.code}`)); return }
|
|
6206
|
+
const d = r.data
|
|
6207
|
+
console.log(`本机入口: ${chalk.green(`http://127.0.0.1:${endpoint.port}`)}`)
|
|
6208
|
+
console.log(`接入: ${d.channelActive ? '微信 + 本机' : '仅本机入口'}|今日 ${d.quota.used}/${d.quota.limit}|LLM ${d.aiConfigured ? '已配置' : '未配置'}`)
|
|
6209
|
+
console.log(chalk.gray(`记忆桶: ${d.memoryBucket}`))
|
|
6210
|
+
if (d.memoryNote) console.log(chalk.gray(` ${d.memoryNote}`))
|
|
6211
|
+
return
|
|
6212
|
+
}
|
|
6213
|
+
|
|
6214
|
+
// ---------------------------------------------------------- 在命令行里问一句
|
|
6215
|
+
if (opts.ask !== undefined) {
|
|
6216
|
+
const text = String(opts.ask || '').trim()
|
|
6217
|
+
if (!text) {
|
|
6218
|
+
if (opts.json) console.log(JSON.stringify({ success: false, code: 'INVALID_ARGUMENT', error: '--ask 不能为空' }))
|
|
6219
|
+
else console.log(chalk.red('--ask 不能为空'))
|
|
6220
|
+
process.exit(1)
|
|
6221
|
+
}
|
|
6222
|
+
if (opts.json && !opts.yes) {
|
|
6223
|
+
console.log(JSON.stringify({ success: false, code: 'CONFIRMATION_REQUIRED', error: '这会真的问助手(可能调用 AI 并占用配额),用 --yes 确认' }))
|
|
6224
|
+
process.exit(1)
|
|
6225
|
+
}
|
|
6226
|
+
const endpoint = readEndpoint()
|
|
6227
|
+
if (!endpoint) {
|
|
6228
|
+
if (opts.json) console.log(JSON.stringify({ success: false, code: 'PANEL_NOT_RUNNING', error: '本机入口没在运行(先 weflow-cli assistant start)' }))
|
|
6229
|
+
else console.log(chalk.yellow('本机入口没在运行(先 weflow-cli assistant start)'))
|
|
6230
|
+
process.exit(1)
|
|
6231
|
+
}
|
|
6232
|
+
const r = await panelAsk(endpoint, text)
|
|
6233
|
+
if (opts.json) {
|
|
6234
|
+
console.log(JSON.stringify(r.ok
|
|
6235
|
+
? { success: true, status: r.data.status, reply: r.data.reply }
|
|
6236
|
+
: { success: false, code: r.code, error: r.error }))
|
|
6237
|
+
if (!r.ok) process.exit(1)
|
|
6238
|
+
return
|
|
6239
|
+
}
|
|
6240
|
+
console.log(r.ok ? r.data.reply : chalk.yellow(`没成: ${r.code}`))
|
|
6241
|
+
return
|
|
6242
|
+
}
|
|
6243
|
+
|
|
6244
|
+
// ---------------------------------------------------------- 打开窗口
|
|
6245
|
+
const { resolveElectronBinary, resolveBrowserBinary, electronLaunchArgs,
|
|
6246
|
+
browserLaunchArgs, browserProfileDir } = await import('../src/panel/launch.js')
|
|
6247
|
+
const { isDaemonAlive, startDaemon } = await import('../src/services/assistantDaemon.js')
|
|
6248
|
+
const { weflowHome } = await import('../src/panel/endpoint.js')
|
|
6249
|
+
|
|
6250
|
+
const electron = resolveElectronBinary({
|
|
6251
|
+
packageRoot: resolvePackageRoot(),
|
|
6252
|
+
localAppData: process.env.LOCALAPPDATA,
|
|
6253
|
+
programFiles: process.env.ProgramFiles,
|
|
6254
|
+
})
|
|
6255
|
+
const browser = electron.found ? { found: null } : resolveBrowserBinary({
|
|
6256
|
+
programFiles: process.env.ProgramFiles,
|
|
6257
|
+
programFilesX86: process.env['ProgramFiles(x86)'],
|
|
6258
|
+
localAppData: process.env.LOCALAPPDATA,
|
|
6259
|
+
})
|
|
6260
|
+
const shell: 'electron' | 'browser' | 'none' = electron.found ? 'electron' : browser.found ? 'browser' : 'none'
|
|
6261
|
+
const running = isDaemonAlive().alive
|
|
6262
|
+
|
|
6263
|
+
const preview = {
|
|
6264
|
+
action: 'panel.open',
|
|
6265
|
+
shell,
|
|
6266
|
+
daemonRunning: running,
|
|
6267
|
+
// 明确声明后果:面板会需要一个守护进程;关掉窗口**不会**顺手把它停掉
|
|
6268
|
+
willStartDaemon: !running,
|
|
6269
|
+
keepsRunningAfterClose: true,
|
|
6270
|
+
endpointFile: endpointFile(),
|
|
6271
|
+
}
|
|
6272
|
+
if (opts.dryRun) {
|
|
6273
|
+
if (opts.json) console.log(JSON.stringify({ success: true, dryRun: true, ...preview }))
|
|
6274
|
+
else console.log(chalk.cyan(`将打开面板(${shell === 'none' ? '没有可用的窗口程序,只会打印地址' : shell})`
|
|
6275
|
+
+ `${running ? '' : ',并启动本机助手守护进程'}`))
|
|
6276
|
+
return
|
|
6277
|
+
}
|
|
6278
|
+
if (opts.json && !opts.yes) {
|
|
6279
|
+
console.log(JSON.stringify({ success: false, code: 'CONFIRMATION_REQUIRED', error: '使用 --yes 确认打开面板', ...preview }))
|
|
6280
|
+
process.exit(1)
|
|
6281
|
+
}
|
|
6282
|
+
|
|
6283
|
+
// 守护进程没起来就先起,并**轮询到真的就绪**——不要相信 spawn 的返回值
|
|
6284
|
+
// (`startDaemon` 里没有 `child.unref()`,stdio 又是 pipe;照抄 daily-reader 的 30×100ms 循环)
|
|
6285
|
+
if (!running) {
|
|
6286
|
+
const started = await startDaemon()
|
|
6287
|
+
if (!started.started && !started.error?.includes('已经运行')) {
|
|
6288
|
+
const msg = `本机助手启动失败: ${started.error ?? '未知原因'}`
|
|
6289
|
+
if (opts.json) console.log(JSON.stringify({ success: false, code: 'ASSISTANT_START_FAILED', error: msg, ...preview }))
|
|
6290
|
+
else console.log(chalk.red(msg))
|
|
6291
|
+
process.exit(1)
|
|
6292
|
+
}
|
|
6293
|
+
}
|
|
6294
|
+
let endpoint = readEndpoint()
|
|
6295
|
+
for (let attempt = 0; attempt < 30 && !endpoint; attempt++) {
|
|
6296
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
6297
|
+
endpoint = readEndpoint()
|
|
6298
|
+
}
|
|
6299
|
+
if (!endpoint) {
|
|
6300
|
+
const msg = '本机入口还没就绪(看 weflow-cli assistant log 里的“本机入口启动失败”,端口可能被占)'
|
|
6301
|
+
if (opts.json) console.log(JSON.stringify({ success: false, code: 'PANEL_NOT_READY', error: msg, ...preview }))
|
|
6302
|
+
else console.log(chalk.red(msg))
|
|
6303
|
+
process.exit(1)
|
|
6304
|
+
}
|
|
6305
|
+
|
|
6306
|
+
const { spawn } = await import('child_process')
|
|
6307
|
+
let launched: string
|
|
6308
|
+
let url: string | null = null
|
|
6309
|
+
if (electron.found) {
|
|
6310
|
+
// argv 里**什么都没有**:不带 token、不带端口、不带 URL(命令行对同机任何进程可见)
|
|
6311
|
+
const child = spawn(electron.found, electronLaunchArgs(join(resolvePackageRoot(), 'resources', 'panel')), {
|
|
6312
|
+
detached: true, stdio: 'ignore', windowsHide: true,
|
|
6313
|
+
})
|
|
6314
|
+
child.unref()
|
|
6315
|
+
launched = 'electron'
|
|
6316
|
+
} else if (browser.found) {
|
|
6317
|
+
// 浏览器那条路只能靠一次性口令:它单次使用、60 秒过期,所以进命令行与浏览器历史都不算泄漏
|
|
6318
|
+
const pair = await panelPair(endpoint)
|
|
6319
|
+
if (!pair.ok) {
|
|
6320
|
+
const msg = `换一次性口令失败: ${pair.code}`
|
|
6321
|
+
if (opts.json) console.log(JSON.stringify({ success: false, code: 'PAIR_FAILED', error: msg, ...preview }))
|
|
6322
|
+
else console.log(chalk.red(msg))
|
|
6323
|
+
process.exit(1)
|
|
6324
|
+
}
|
|
6325
|
+
url = pair.data.url
|
|
6326
|
+
const child = spawn(browser.found, browserLaunchArgs({ url, profileDir: browserProfileDir(weflowHome()) }), {
|
|
6327
|
+
detached: true, stdio: 'ignore', windowsHide: true,
|
|
6328
|
+
})
|
|
6329
|
+
child.unref()
|
|
6330
|
+
launched = 'browser'
|
|
6331
|
+
} else {
|
|
6332
|
+
// 两条路都没有:**别静默什么都不做**,把地址交出去
|
|
6333
|
+
const pair = await panelPair(endpoint)
|
|
6334
|
+
url = pair.ok ? pair.data.url : `http://127.0.0.1:${endpoint.port}/panel`
|
|
6335
|
+
launched = 'none'
|
|
6336
|
+
}
|
|
6337
|
+
|
|
6338
|
+
if (opts.json) {
|
|
6339
|
+
// 展开在前、实际结果在后:`preview.shell` 是"打算用哪个",这里是"实际用了哪个",
|
|
6340
|
+
// 两者可能不同(例如打算用 Electron 但 spawn 之后没起来)——以后者为准
|
|
6341
|
+
console.log(JSON.stringify({ ...preview, success: true, shell: launched, port: endpoint.port, url }))
|
|
6342
|
+
return
|
|
6343
|
+
}
|
|
6344
|
+
if (launched === 'electron') {
|
|
6345
|
+
console.log(chalk.green(`✓ 面板已启动(记忆桶 ${endpoint.memoryBucket})`))
|
|
6346
|
+
console.log(chalk.gray(' 关掉窗口只是隐藏;助手仍在后台跑,要停它用 weflow-cli assistant stop'))
|
|
6347
|
+
} else if (launched === 'browser') {
|
|
6348
|
+
console.log(chalk.green('✓ 已用浏览器打开面板窗口'))
|
|
6349
|
+
console.log(chalk.yellow(' 注意:这条路给不了悬浮球——浏览器窗口不能无边框/置顶,也没有托盘与快捷键。'))
|
|
6350
|
+
console.log(chalk.gray(' 想要真·悬浮球:npm i -g electron 之后再跑一次'))
|
|
6351
|
+
} else {
|
|
6352
|
+
console.log(chalk.yellow('没找到 Electron,也没找到 Edge/Chrome。自己打开这个地址即可:'))
|
|
6353
|
+
console.log(chalk.cyan(` ${url}`))
|
|
6354
|
+
console.log(chalk.gray(' 想要悬浮球:npm i -g electron'))
|
|
6355
|
+
}
|
|
6356
|
+
})
|
|
6357
|
+
|
|
5139
6358
|
program
|
|
5140
6359
|
.command('daily-stats')
|
|
5141
6360
|
.description('统计公众号推送频率与日报处理频率')
|
|
@@ -5321,10 +6540,10 @@ program
|
|
|
5321
6540
|
['wechat.search_public', '搜索全网公众号文章'],
|
|
5322
6541
|
['wechat.export_messages', '按稳定数据契约读取会话消息'],
|
|
5323
6542
|
]
|
|
5324
|
-
const {
|
|
6543
|
+
const { MCP_TOOL_DEFS } = await import('../src/services/assistantTools.js')
|
|
5325
6544
|
const all = [
|
|
5326
6545
|
...STATIC_TOOLS,
|
|
5327
|
-
...
|
|
6546
|
+
...MCP_TOOL_DEFS.filter(t => t.function.name !== 'get_stats')
|
|
5328
6547
|
.map(t => [`wechat.${t.function.name}`, t.function.description.split(/[。(]/)[0]] as [string, string]),
|
|
5329
6548
|
]
|
|
5330
6549
|
for (const [n, d] of all) {
|