weflow-cli 1.8.2 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/CHANGELOG.md +342 -0
  2. package/OPERATIONS.md +79 -0
  3. package/README.en.md +9 -2
  4. package/README.md +12 -2
  5. package/bin/weflow-cli-electron.cjs +2 -0
  6. package/bin/weflow-cli.ts +648 -79
  7. package/dist/bin/weflow-cli.js +675 -78
  8. package/dist/bin/weflow-cli.js.map +1 -1
  9. package/dist/mcp-server/http.d.ts +14 -0
  10. package/dist/mcp-server/http.d.ts.map +1 -0
  11. package/dist/mcp-server/http.js +84 -0
  12. package/dist/mcp-server/http.js.map +1 -0
  13. package/dist/mcp-server/httpConfig.d.ts +50 -0
  14. package/dist/mcp-server/httpConfig.d.ts.map +1 -0
  15. package/dist/mcp-server/httpConfig.js +104 -0
  16. package/dist/mcp-server/httpConfig.js.map +1 -0
  17. package/dist/mcp-server/index.d.ts +13 -1
  18. package/dist/mcp-server/index.d.ts.map +1 -1
  19. package/dist/mcp-server/index.js +39 -373
  20. package/dist/mcp-server/index.js.map +1 -1
  21. package/dist/src/panel/server.d.ts.map +1 -1
  22. package/dist/src/panel/server.js +26 -1
  23. package/dist/src/panel/server.js.map +1 -1
  24. package/dist/src/services/assistantEval.d.ts +69 -0
  25. package/dist/src/services/assistantEval.d.ts.map +1 -1
  26. package/dist/src/services/assistantEval.js +270 -8
  27. package/dist/src/services/assistantEval.js.map +1 -1
  28. package/dist/src/services/assistantScenes.d.ts +81 -0
  29. package/dist/src/services/assistantScenes.d.ts.map +1 -0
  30. package/dist/src/services/assistantScenes.js +322 -0
  31. package/dist/src/services/assistantScenes.js.map +1 -0
  32. package/dist/src/services/assistantService.d.ts +14 -4
  33. package/dist/src/services/assistantService.d.ts.map +1 -1
  34. package/dist/src/services/assistantService.js +82 -6
  35. package/dist/src/services/assistantService.js.map +1 -1
  36. package/dist/src/services/assistantSkills.d.ts +94 -0
  37. package/dist/src/services/assistantSkills.d.ts.map +1 -0
  38. package/dist/src/services/assistantSkills.js +330 -0
  39. package/dist/src/services/assistantSkills.js.map +1 -0
  40. package/dist/src/services/assistantTools.d.ts +7 -0
  41. package/dist/src/services/assistantTools.d.ts.map +1 -1
  42. package/dist/src/services/assistantTools.js +770 -61
  43. package/dist/src/services/assistantTools.js.map +1 -1
  44. package/dist/src/services/assistantTrace.d.ts +4 -0
  45. package/dist/src/services/assistantTrace.d.ts.map +1 -1
  46. package/dist/src/services/assistantTrace.js +9 -0
  47. package/dist/src/services/assistantTrace.js.map +1 -1
  48. package/dist/src/services/configService.d.ts +7 -0
  49. package/dist/src/services/configService.d.ts.map +1 -1
  50. package/dist/src/services/configService.js +4 -2
  51. package/dist/src/services/configService.js.map +1 -1
  52. package/dist/src/services/outboundSend.d.ts +53 -0
  53. package/dist/src/services/outboundSend.d.ts.map +1 -0
  54. package/dist/src/services/outboundSend.js +90 -0
  55. package/dist/src/services/outboundSend.js.map +1 -0
  56. package/dist/src/services/pythonBridge.d.ts.map +1 -1
  57. package/dist/src/services/pythonBridge.js +1 -0
  58. package/dist/src/services/pythonBridge.js.map +1 -1
  59. package/dist/src/utils/python.js +2 -2
  60. package/dist/src/utils/python.js.map +1 -1
  61. package/docs/BRANCHES.md +37 -0
  62. package/docs/DECISIONS.md +350 -0
  63. package/docs/EXTENDING.md +29 -3
  64. package/docs/MCP.md +56 -2
  65. package/docs/PROJECT_STATE.md +121 -9
  66. package/mcp-server/http.ts +108 -0
  67. package/mcp-server/httpConfig.ts +126 -0
  68. package/mcp-server/index.ts +344 -681
  69. package/package.json +1 -1
  70. package/resources/panel/ball-position.cjs +48 -2
  71. package/resources/panel/index.html +6 -4
  72. package/resources/panel/main.cjs +137 -5
  73. package/resources/panel/mascot-base.png +0 -0
  74. package/resources/panel/mascot-iris.png +0 -0
  75. package/resources/panel/mascot-peek.png +0 -0
  76. package/resources/panel/panel.css +95 -60
  77. package/resources/panel/preload.cjs +26 -0
  78. package/resources/panel/renderer.js +167 -10
  79. package/scripts/_utils.py +47 -1
  80. package/scripts/backfill_articles.py +202 -21
  81. package/scripts/biz_daily.py +8 -31
  82. package/scripts/bonds.py +331 -0
  83. package/scripts/chat_notes.py +551 -470
  84. package/scripts/chat_stats.py +8 -7
  85. package/scripts/compile_wiki.py +24 -2
  86. package/scripts/export_chat_html.py +3 -1
  87. package/scripts/mcp_bridge.py +6 -7
  88. package/scripts/wechat_voice.py +9 -0
  89. package/scripts/wiki_lint.py +30 -7
  90. package/src/panel/server.ts +25 -1
  91. package/src/services/assistantEval.ts +323 -12
  92. package/src/services/assistantScenes.ts +342 -0
  93. package/src/services/assistantService.ts +81 -6
  94. package/src/services/assistantSkills.ts +364 -0
  95. package/src/services/assistantTools.ts +738 -59
  96. package/src/services/assistantTrace.ts +9 -0
  97. package/src/services/configService.ts +412 -403
  98. package/src/services/outboundSend.ts +126 -0
  99. package/src/services/pythonBridge.ts +1 -0
  100. package/src/utils/python.ts +2 -2
@@ -83,13 +83,14 @@ def get_biz_keys(config):
83
83
  contact_salt = config.get('contactSalt', '')
84
84
  contact_key = decrypt_lock(contact_key_enc) if contact_key_enc else ''
85
85
 
86
- biz_db = os.path.join(msg_dir, 'biz_message_0.db')
87
- biz_key_enc = config.get('bizKey', '')
88
- biz_salt = config.get('bizSalt', '')
89
- if biz_key_enc and biz_salt:
90
- biz_key = decrypt_lock(biz_key_enc)
91
- else:
92
- print('[ERROR] 缺少 biz_message_0.db 密钥,请运行: python scripts/nt_decrypt.py scan --json')
86
+ # 钥匙派生统一到 `_utils.biz_message_db()`:原来这里只认配置里的 bizKey/bizSalt,
87
+ # 而那两个键在本机根本不存在(微信 4.x 的钥匙由全库 passphrase 派生)——
88
+ # 照这条提示去 `config set bizKey` 还会撞上"该键不在 CLI 可写白名单里"。
89
+ from _utils import biz_message_db
90
+ try:
91
+ biz_db, biz_key, biz_salt = biz_message_db(config)
92
+ except RuntimeError as exc:
93
+ print('[ERROR] %s' % exc)
93
94
  sys.exit(1)
94
95
 
95
96
  return {
@@ -681,11 +681,20 @@ def build_pages_from_cards(pages_dir: str, card_dirs=None, dry_run: bool = False
681
681
  existing = {p.stem.casefold() for p in Path(pages_dir).glob('*.md')}
682
682
  out_dir = Path(pages_dir)
683
683
  today = time.strftime('%Y-%m-%d')
684
- built, skipped, unwritable, no_material, bad_source = [], 0, 0, 0, 0
684
+ # **只差大小写的两个概念名,在 Windows 上是同一个文件。** 原来的实现让后一个覆盖前一个:
685
+ # 一页**没了**,而 `built` 把两个都算了(实测 49,953 页里丢 53:AI 那批 44、这批 9)。
686
+ # 按"文件名(大小写折叠后)"分组 —— 一个文件一张页,其余写法进 `aliases`。这不是新规矩:
687
+ # `--merge-duplicates` 对"同一个概念的不同写法"做的就是这件事,`wiki_lint` 也一直认别名。
688
+ groups: dict = {}
685
689
  for name, refs in merged.items():
690
+ groups.setdefault(re.sub(r'[\/:*?"<>|]', '_', name)[:60].casefold(), []).append((name, refs))
691
+
692
+ built, skipped, unwritable, no_material, bad_source = [], 0, 0, 0, 0
693
+ for members in groups.values():
694
+ name, refs = members[0] # 组里第一个当正式名,其余进 aliases
686
695
  safe = re.sub(r'[\/:*?"<>|]', '_', name)[:60]
687
696
  if safe.casefold() in existing:
688
- skipped += 1
697
+ skipped += len(members)
689
698
  continue
690
699
  if '[' in name or ']' in name or name.endswith('.md'):
691
700
  # 同 `--refresh-sources`:这两类名字体检会报断链(`wiki_lint.resolve` 见到 `.md`
@@ -723,6 +732,19 @@ def build_pages_from_cards(pages_dir: str, card_dirs=None, dry_run: bool = False
723
732
  'sources': [ref['file']],
724
733
  'summary_by': 'card',
725
734
  }
735
+ # 别名有两类来源,都是**指向这一页的名字**:
736
+ # 1. 需要净化/截断时保留的原名 —— 否则 Obsidian 里 `[[Qwen3.5:9B]]` 点不开
737
+ # `Qwen3.5_9B.md`,`wiki_lint` 也会把这页报成孤儿(2026-10-01 实测 28 张);
738
+ # 2. 同一批里只差大小写的另一种写法 —— 它们本来指向同一张页。
739
+ # 组里的写法也要过"写不得"那关:带方括号或以 `.md` 结尾的名字进了 aliases
740
+ # 只会给体检添一批假断链(与上面概念名那条同一个理由)。
741
+ aliases = [name] if safe != name else []
742
+ for other, _ in members[1:]:
743
+ if other in aliases or '[' in other or ']' in other or other.endswith('.md'):
744
+ continue
745
+ aliases.append(other)
746
+ if aliases:
747
+ fm['aliases'] = aliases
726
748
  if not dry_run:
727
749
  out_dir.mkdir(parents=True, exist_ok=True)
728
750
  write_with_frontmatter(str(out_dir / ('%s.md' % safe)), fm, body)
@@ -2379,7 +2379,9 @@ def main():
2379
2379
  # Voice: load the payloads so a cached transcript can be shown. Anything
2380
2380
  # not yet transcribed simply renders as `[语音 N″]`.
2381
2381
  if _WECHAT_VOICE:
2382
- VOICE_STATE['cache'] = wechat_voice.TranscriptCache(os.path.join(args.out, '.voice-cache'))
2382
+ # 缓存的家由 `wechat_voice` 定义:以前这里按 `--out` 拼,于是同一段音频在不同
2383
+ # 输出目录下会被各转写一遍,而卡片那条线读的又是另一处(2026-10-01)。
2384
+ VOICE_STATE['cache'] = wechat_voice.TranscriptCache(wechat_voice.DEFAULT_CACHE_DIR)
2383
2385
  try:
2384
2386
  VOICE_STATE['map'] = wechat_voice.load_voice_map(
2385
2387
  args.db, args.key, args.salt, args.passphrase, args.talker)
@@ -69,13 +69,12 @@ def get_biz_keys(config):
69
69
  contact_key_enc = config.get('contactKey', '')
70
70
  contact_salt = config.get('contactSalt', '')
71
71
  contact_key = decrypt_lock(contact_key_enc) if contact_key_enc else ''
72
- biz_db = os.path.join(msg_dir, 'biz_message_0.db')
73
- biz_key_enc = config.get('bizKey', '')
74
- biz_salt = config.get('bizSalt', '')
75
- if biz_key_enc and biz_salt:
76
- biz_key = decrypt_lock(biz_key_enc)
77
- else:
78
- return {'error': '缺少 biz_message_0.db 密钥,请运行 python scripts/nt_decrypt.py scan --json'}
72
+ # 钥匙派生统一到 `_utils.biz_message_db()`(原来只认 bizKey/bizSalt,见那个函数的注释)
73
+ from _utils import biz_message_db
74
+ try:
75
+ biz_db, biz_key, biz_salt = biz_message_db(config)
76
+ except RuntimeError as exc:
77
+ return {'error': str(exc)}
79
78
  return {
80
79
  'biz_db': biz_db, 'biz_key': biz_key, 'biz_salt': biz_salt,
81
80
  'contact_db': contact_db, 'contact_key': contact_key, 'contact_salt': contact_salt,
@@ -33,6 +33,15 @@ EMPTY_MARKER = '\x00empty'
33
33
 
34
34
  DEFAULT_MODEL = 'small'
35
35
 
36
+ # **转写缓存的家**,由这个模块定义,别处一律引用它。
37
+ #
38
+ # 2026-10-01 踩过一次:`chat_notes` 拿自己的输出目录(`output/chat-notes`)去拼缓存路径,于是
39
+ # 它读到的是 `output/chat-notes/.voice-cache` —— 一个刚被它自己建出来的空目录,而真正的缓存
40
+ # 在 `output/.voice-cache`(1,788 条)。它「命中 16 条」那个数字是自洽的,只是与真缓存不相通,
41
+ # 报告看不出来。位置只要有两份定义就会漂,所以从这里定一份。
42
+ # 可用 `WEFLOW_VOICE_CACHE` 覆盖(测试与自定义布局用)。
43
+ DEFAULT_CACHE_DIR = os.environ.get('WEFLOW_VOICE_CACHE') or os.path.join('output', '.voice-cache')
44
+
36
45
  # A Cantonese-tuned model, fetched separately (see the README note in
37
46
  # OPERATIONS.md). Worth preferring automatically when present: stock Whisper
38
47
  # answers Cantonese speech with fluent-looking Mandarin nonsense - text that
@@ -144,15 +144,28 @@ def inspect(pages: list, exists, card_links=()) -> dict:
144
144
  """
145
145
  stems = {page['stem'] for page in pages}
146
146
  inbound = {stem: 0 for stem in stems}
147
+ # **别名要归位到它那一页,否则那张页永远被报成孤儿。** 文件名是概念名的一个有损变换
148
+ # (`:` `/ ? * " < > |` 换成 `_`,再截到 60 字符),而指向它的链接用的是**原名**:
149
+ # `resolvable_names` 早就把别名算进"存在"了(所以断链是 0),但**入链计数**这里原来只认页码
150
+ # —— 名字对不上,卡片那条链接既不计入链也不报断链,那一页于是在图里是孤岛。
151
+ # (2026-10-01 实测:`[[Qwen3.5:9B]]` → `Qwen3.5_9B.md`,49,953 页里有 28 张。)
152
+ alias_to_stem = {str(alias): page['stem']
153
+ for page in pages
154
+ for alias in (page.get('aliases') or [])
155
+ if str(alias) not in stems}
156
+
157
+ def stem_of(name: str) -> str:
158
+ return alias_to_stem.get(name, name)
159
+
147
160
  for name in card_links: # 卡片 → 概念,这是主要入链
148
- if not (name.endswith('.md') or '/' in name) and name in inbound:
149
- inbound[name] += 1
161
+ if not (name.endswith('.md') or '/' in name) and stem_of(name) in inbound:
162
+ inbound[stem_of(name)] += 1
150
163
  broken, aspirational, empty = [], [], []
151
164
  for page in pages:
152
165
  for section, name in links_by_section(page['body']):
153
166
  if exists(name):
154
- if not (name.endswith('.md') or '/' in name) and name in inbound:
155
- inbound[name] += 1
167
+ if not (name.endswith('.md') or '/' in name) and stem_of(name) in inbound:
168
+ inbound[stem_of(name)] += 1
156
169
  continue
157
170
  entry = {'page': page['stem'], 'target': name, 'section': section}
158
171
  if any(section.startswith(prefix) for prefix in RELATED_SECTIONS):
@@ -326,12 +339,16 @@ def collect(pages_dir: str, card_dirs) -> list:
326
339
  # 判重按目录分开比(两个目录是两个知识库),所以要记住这张页住哪儿
327
340
  'dir': str(pages_dir),
328
341
  'topic': compile_frontmatter_topic(frontmatter),
329
- 'title': str(frontmatter.get('title') or '').strip('"'),
342
+ # `parse_frontmatter` 已经把外层引号拆掉了,**这里不能再剥一次**:
343
+ # `strip('"')` 会连名字**自己末尾**的引号一起吃掉,`AI 长出"手脚"` 变成
344
+ # `AI 长出"手脚` —— 然后它与卡片里那条链接差一个字符,永远匹配不上,
345
+ # 那页就被报成孤儿(2026-10-01 实测,49,953 页里 1 张)。
346
+ 'title': str(frontmatter.get('title') or ''),
330
347
  # **别名也算"指向这一页的名字"**:`--merge-duplicates` 把
331
348
  # `GPT-5.6` 并进 `GPT 5.6` 时就是靠 aliases 让旧链接不断的,
332
349
  # Obsidian 认它。体检不认的话,会把这些名字报成"还没建页、
333
350
  # 建议再跑 compile" —— 而它们已经有页了。
334
- 'aliases': [str(a).strip().strip('"').strip("'") for a in raw_aliases],
351
+ 'aliases': [str(a).strip() for a in raw_aliases],
335
352
  'links': extract_links(body),
336
353
  'body': body})
337
354
  return pages
@@ -364,8 +381,14 @@ def main():
364
381
  # 只查卡片目录会把这类全报成断链——实测 1,950 条,而它们在 Obsidian 里都是好的
365
382
  # (两边文件名本来就不同:卡片保留完整标题,阅读笔记截到 50 字)。
366
383
  card_stems = collect_card_stems(tuple(CARD_DIRS) + MATERIAL_DIRS)
384
+ resolvable = resolvable_names(pages) # 只算一次(见下面那条注释)
367
385
  report = inspect(pages,
368
- lambda name: resolve(name, resolvable_names(pages), CARD_DIRS, card_stems),
386
+
387
+ # **注意别把它写回 lambda 里**:闭包体是**调用时**求值的,
388
+ # 写在里面等于每条链接都重建一次全部页名的集合 —— 实测文章线
389
+ # (3,623 页)因此要 9 分 29 秒,聊天线(1,527 页)只要 3.2 秒。
390
+ # 提出来算一次之后是同一条线的秒级。
391
+ lambda name: resolve(name, resolvable, CARD_DIRS, card_stems),
369
392
  card_links)
370
393
  report['success'] = True
371
394
  report['pagesDir'] = existing
@@ -34,6 +34,7 @@ import {
34
34
  writeEndpoint, type PanelChannelMode, type PanelEndpoint,
35
35
  } from './endpoint.js'
36
36
  import type { AssistantService, TurnOutcome } from '../services/assistantService.js'
37
+ import { describeForPanel } from '../services/assistantTrace.js'
37
38
  import { privacyGate } from '../services/assistantPrivacy.js'
38
39
  import { configService } from '../services/configService.js'
39
40
  import { resolvePackageRoot } from '../utils/packageRoot.js'
@@ -66,6 +67,16 @@ const STATIC_FILES: Record<string, string> = {
66
67
  '/panel/mascot-thinking.png': 'mascot-thinking.png',
67
68
  '/panel/mascot-sorry.png': 'mascot-sorry.png',
68
69
  '/panel/mascot-tired.png': 'mascot-tired.png',
70
+ // 眼球跟随鼠标用的两层(2026-09-30):`mascot-base.png` 是**把虹膜挖掉**的球面、
71
+ // `mascot-iris.png` 是可以单独平移的虹膜层(含瞳孔与高光)。零偏移叠回去与原图**逐像素相同**
72
+ // (脚本验过),所以换上去不会看出任何差别,只是眼睛能动了。
73
+ // 同上:**漏一张,球在那个瞬间闪成空图**,而 PANEL_ASSET_MISSING 只写在响应里、页面上看不到。
74
+ '/panel/mascot-base.png': 'mascot-base.png',
75
+ '/panel/mascot-iris.png': 'mascot-iris.png',
76
+ // 半隐时那张脸(2026-09-30):身子藏在屏幕边后面、只把脑袋探出来。
77
+ // 它的左边**必须是一条笔直的切边**(内容贴着画布 x=0),贴上去才跟屏幕边严丝合缝——
78
+ // 所以半隐时球的 `border-radius` 也要去掉(圆会把那条切边剪成弧)。
79
+ '/panel/mascot-peek.png': 'mascot-peek.png',
69
80
  }
70
81
  const CONTENT_TYPES: Record<string, string> = {
71
82
  '.html': 'text/html; charset=utf-8',
@@ -346,7 +357,20 @@ export async function startPanelServer(options: PanelServerOptions): Promise<Pan
346
357
  }
347
358
  const outcome = raced.value
348
359
  if (outcome.status === 'replied' || outcome.status === 'quota-exceeded') {
349
- json(res, 200, { ok: true, status: outcome.status, reply: outcome.text, memoryBucket })
360
+ // 顺带把这一轮的**轨迹**给页面:它折叠着显示成"思考过程"(默认收起)。
361
+ // 与微信里发「轨迹」看到的**是同一份**(都来自 `traces`,同一个 `describeForChat` 格式,
362
+ // 同样不含 userId)。没有轨迹就给空对象——页面据此不显示那一块。
363
+ //
364
+ // **它出错不许把答复弄丢**:轨迹是附加物,答复才是这一轮的结果。所以这里 catch 住——
365
+ // 顶多少一块"思考过程",不能因为附加物把 200 变成 500(第一版没包,测试立刻抓到:
366
+ // 桩服务没有 `recentTrace`,于是每条 ask 都成了 500)。
367
+ let trace = { lines: [] as string[], reasoning: '' }
368
+ try {
369
+ trace = describeForPanel(service.recentTrace(memoryBucket))
370
+ } catch { /* 附加物失败不影响答复 */ }
371
+ json(res, 200, {
372
+ ok: true, status: outcome.status, reply: outcome.text, memoryBucket, trace,
373
+ })
350
374
  return
351
375
  }
352
376
  if (outcome.status === 'not-running') {