weflow-cli 1.9.0 → 1.11.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 (134) hide show
  1. package/ARCHITECTURE.md +62 -26
  2. package/CHANGELOG.md +1051 -0
  3. package/OPERATIONS.md +99 -13
  4. package/README.en.md +14 -5
  5. package/README.md +23 -8
  6. package/bin/weflow-cli.ts +364 -40
  7. package/dist/bin/weflow-cli.js +381 -69
  8. package/dist/bin/weflow-cli.js.map +1 -1
  9. package/dist/mcp-server/index.d.ts.map +1 -1
  10. package/dist/mcp-server/index.js +19 -1
  11. package/dist/mcp-server/index.js.map +1 -1
  12. package/dist/src/core/dbPathService.d.ts +12 -0
  13. package/dist/src/core/dbPathService.d.ts.map +1 -1
  14. package/dist/src/core/dbPathService.js +20 -3
  15. package/dist/src/core/dbPathService.js.map +1 -1
  16. package/dist/src/panel/server.d.ts.map +1 -1
  17. package/dist/src/panel/server.js +35 -0
  18. package/dist/src/panel/server.js.map +1 -1
  19. package/dist/src/services/assistantService.d.ts +19 -0
  20. package/dist/src/services/assistantService.d.ts.map +1 -1
  21. package/dist/src/services/assistantService.js +20 -0
  22. package/dist/src/services/assistantService.js.map +1 -1
  23. package/dist/src/services/assistantTools.d.ts +27 -1
  24. package/dist/src/services/assistantTools.d.ts.map +1 -1
  25. package/dist/src/services/assistantTools.js +169 -35
  26. package/dist/src/services/assistantTools.js.map +1 -1
  27. package/dist/src/services/configService.d.ts +2 -0
  28. package/dist/src/services/configService.d.ts.map +1 -1
  29. package/dist/src/services/configService.js +3 -2
  30. package/dist/src/services/configService.js.map +1 -1
  31. package/dist/src/services/exportService.d.ts +43 -0
  32. package/dist/src/services/exportService.d.ts.map +1 -1
  33. package/dist/src/services/exportService.js +116 -11
  34. package/dist/src/services/exportService.js.map +1 -1
  35. package/dist/src/services/messageQuery.d.ts +18 -0
  36. package/dist/src/services/messageQuery.d.ts.map +1 -1
  37. package/dist/src/services/messageQuery.js +29 -0
  38. package/dist/src/services/messageQuery.js.map +1 -1
  39. package/dist/src/services/wechatClient.d.ts +125 -0
  40. package/dist/src/services/wechatClient.d.ts.map +1 -0
  41. package/dist/src/services/wechatClient.js +344 -0
  42. package/dist/src/services/wechatClient.js.map +1 -0
  43. package/dist/src/services/wechatMedia.d.ts +44 -0
  44. package/dist/src/services/wechatMedia.d.ts.map +1 -0
  45. package/dist/src/services/wechatMedia.js +155 -0
  46. package/dist/src/services/wechatMedia.js.map +1 -0
  47. package/dist/src/services/wechatMessageService.d.ts +11 -0
  48. package/dist/src/services/wechatMessageService.d.ts.map +1 -1
  49. package/dist/src/services/wechatMessageService.js +55 -11
  50. package/dist/src/services/wechatMessageService.js.map +1 -1
  51. package/dist/src/types.d.ts +4 -0
  52. package/dist/src/types.d.ts.map +1 -1
  53. package/docs/CONTACT_DB_SCHEMA.md +1085 -0
  54. package/docs/DECISIONS.md +1274 -0
  55. package/docs/EXTENDING.md +69 -7
  56. package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +27 -0
  57. package/docs/HEALTH-CHECK.md +12 -7
  58. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +2 -2
  59. package/docs/MCP.md +2 -2
  60. package/docs/PROJECT_STATE.md +64 -5
  61. package/docs/RELEASING.md +128 -1
  62. package/docs/images/weflow-architecture-gpt-image-2.png +0 -0
  63. package/logo.png +0 -0
  64. package/mcp-server/index.ts +18 -3
  65. package/package.json +5 -3
  66. package/resources/js/graph3d/NOTICE.txt +35 -0
  67. package/resources/js/graph3d/d3-binarytree.min.js +2 -0
  68. package/resources/js/graph3d/d3-dispatch.min.js +2 -0
  69. package/resources/js/graph3d/d3-force-3d.min.js +2 -0
  70. package/resources/js/graph3d/d3-octree.min.js +2 -0
  71. package/resources/js/graph3d/d3-quadtree.min.js +2 -0
  72. package/resources/js/graph3d/d3-timer.min.js +2 -0
  73. package/resources/js/graph3d/layout.mjs +63 -0
  74. package/resources/js/graph3d/three.min.js +6 -0
  75. package/resources/js/graph3d/viewer2d.js +448 -0
  76. package/resources/panel/index.html +12 -0
  77. package/resources/panel/mascot-blink-1.png +0 -0
  78. package/resources/panel/mascot-blink-2.png +0 -0
  79. package/resources/panel/mascot-lift-down-1.png +0 -0
  80. package/resources/panel/mascot-lift-down-2.png +0 -0
  81. package/resources/panel/mascot-lift-down-3.png +0 -0
  82. package/resources/panel/mascot-lift-down-4.png +0 -0
  83. package/resources/panel/mascot-lift-down-5.png +0 -0
  84. package/resources/panel/mascot-lift-down-6.png +0 -0
  85. package/resources/panel/mascot-lift-up-1.png +0 -0
  86. package/resources/panel/mascot-lift-up-2.png +0 -0
  87. package/resources/panel/mascot-lift-up-3.png +0 -0
  88. package/resources/panel/mascot-lift-up-4.png +0 -0
  89. package/resources/panel/mascot-lift-up-5.png +0 -0
  90. package/resources/panel/mascot-lift-up-6.png +0 -0
  91. package/resources/panel/mascot-tickle-1.png +0 -0
  92. package/resources/panel/mascot-tickle-2.png +0 -0
  93. package/resources/panel/mascot-tickle-3.png +0 -0
  94. package/resources/panel/mascot-tickle-4.png +0 -0
  95. package/resources/panel/panel.css +111 -0
  96. package/resources/panel/renderer.js +480 -8
  97. package/scripts/_utils.py +1203 -977
  98. package/scripts/biz_daily.py +25 -10
  99. package/scripts/chat_notes.py +15 -5
  100. package/scripts/check_readme_commands.py +426 -0
  101. package/scripts/compile_wiki.py +163 -57
  102. package/scripts/contact_schema.py +339 -0
  103. package/scripts/data/wechat_emoji.json +225 -0
  104. package/scripts/export_chat_html.py +346 -6
  105. package/scripts/fav_notes.py +4 -1
  106. package/scripts/graph_2d.py +151 -0
  107. package/scripts/graph_3d.py +646 -0
  108. package/scripts/health_check.py +163 -26
  109. package/scripts/nt_decrypt.py +3 -3
  110. package/scripts/panel_frames.py +683 -0
  111. package/scripts/pipeline.py +21 -10
  112. package/scripts/quality_eval.py +5 -0
  113. package/scripts/rag_chat.py +33 -16
  114. package/scripts/reply_debt.py +26 -6
  115. package/scripts/semantic_search.py +212 -58
  116. package/scripts/vault_rag.py +42 -16
  117. package/scripts/vault_search.py +44 -8
  118. package/scripts/wechat_emoticon.py +6 -0
  119. package/scripts/wiki_lint.py +94 -17
  120. package/src/core/dbPathService.ts +22 -4
  121. package/src/panel/server.ts +33 -0
  122. package/src/services/assistantService.ts +26 -0
  123. package/src/services/assistantTools.ts +2452 -2319
  124. package/src/services/configService.ts +5 -2
  125. package/src/services/exportService.ts +129 -12
  126. package/src/services/messageQuery.ts +29 -0
  127. package/src/services/wechatClient.ts +380 -0
  128. package/src/services/wechatMedia.ts +166 -0
  129. package/src/services/wechatMessageService.ts +62 -11
  130. package/src/types.ts +4 -0
  131. package/dist/src/utils/wechatEmoji.d.ts +0 -5
  132. package/dist/src/utils/wechatEmoji.d.ts.map +0 -1
  133. package/dist/src/utils/wechatEmoji.js +0 -35
  134. package/dist/src/utils/wechatEmoji.js.map +0 -1
@@ -13,11 +13,24 @@ from datetime import datetime, timedelta
13
13
 
14
14
  SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))
15
15
  sys.path.insert(0, SCRIPTS_DIR)
16
- from _utils import CONCEPT_DIRS # noqa: E402
16
+ from _utils import (CONCEPT_DIRS, LINE_LABELS, line_for_relative_concepts) # noqa: E402
17
17
  PROJECT_ROOT = os.path.dirname(SCRIPTS_DIR)
18
18
  DEFAULT_VAULT = os.path.join(PROJECT_ROOT, 'output', 'wechat-vault')
19
19
  DEFAULT_BIZ = os.path.join(PROJECT_ROOT, 'output', 'biz-daily')
20
20
 
21
+ LINE_CHOICES = ('all', 'wiki', 'chat')
22
+
23
+ # 笔记层里**哪些属于聊天线**。其余笔记层是用户自己写的/读的(文章线)。
24
+ #
25
+ # 两个知识库分开之后,`--line` 要能真的把材料也分开,否则"只看聊天线"仍然会从用户的
26
+ # 阅读笔记里捞结果——那不是聊天知识库里的东西。`Sources/Chat` 是 `chat_notes --vault-copy`
27
+ # 放聊天卡的地方(`scripts/chat_notes.py`),只有它是聊天线的。
28
+ CHAT_NOTE_DIRS = ('Sources/Chat',)
29
+
30
+
31
+ def line_label(line: str) -> str:
32
+ return '两条线' if line == 'all' else LINE_LABELS.get(line, line)
33
+
21
34
 
22
35
  def search_text(query: str, files: list[tuple], top_k: int) -> list[dict]:
23
36
  """关键词搜索 + 简单排序。"""
@@ -43,10 +56,19 @@ def search_text(query: str, files: list[tuple], top_k: int) -> list[dict]:
43
56
  return scored[:top_k]
44
57
 
45
58
 
46
- def collect_files(vault: str, biz_daily: str, search_type: str, days: int) -> list[tuple]:
47
- """收集可搜索文件。"""
59
+ def collect_files(vault: str, biz_daily: str, search_type: str, days: int,
60
+ line: str = 'all') -> list[tuple]:
61
+ """收集可搜索文件。
62
+
63
+ `line` 在**收集这一步**就过滤(而不是收完再筛):三条来源各自属于哪条线是这里才知道的
64
+ —— 日报文章与用户的笔记层是文章线、`Sources/Chat` 的聊天卡是聊天线、概念页按目录分。
65
+
66
+ **函数默认 `all`(不缩小范围)**,用户看到的默认(文章线)在 CLI 那一层:忘了传 `line`
67
+ 的调用方会多搜一条线,而不是静默少搜一半。
68
+ """
48
69
  files = []
49
70
  cutoff = datetime.now() - timedelta(days=days)
71
+ want = (lambda which: line == 'all' or line == which)
50
72
 
51
73
  def parse_fm(text):
52
74
  if not text.startswith('---'):
@@ -61,7 +83,7 @@ def collect_files(vault: str, biz_daily: str, search_type: str, days: int) -> li
61
83
  fm[k.strip()] = v.strip().strip('"').strip("'")
62
84
  return fm
63
85
 
64
- if search_type in ('all', 'article'):
86
+ if search_type in ('all', 'article') and want('wiki'):
65
87
  # biz-daily 文章
66
88
  biz_dir = Path(biz_daily)
67
89
  for date_dir in sorted(biz_dir.glob('20*'), reverse=True):
@@ -86,6 +108,10 @@ def collect_files(vault: str, biz_daily: str, search_type: str, days: int) -> li
86
108
  # ——命令照样返回结果,只是少了一半。
87
109
  vault_path = Path(vault)
88
110
  for relative in CONCEPT_DIRS:
111
+ # 概念页**按目录分线**(清单里每条线一个概念目录),而不是按库根——
112
+ # 测试的临时库、或 `--vault` 指到别处时,库根对不上但"这串相对路径属于哪条线"仍在
113
+ if not want(line_for_relative_concepts(relative)):
114
+ continue
89
115
  concepts_dir = vault_path / relative
90
116
  if not concepts_dir.is_dir():
91
117
  continue
@@ -96,7 +122,7 @@ def collect_files(vault: str, biz_daily: str, search_type: str, days: int) -> li
96
122
  fm = {}
97
123
  files.append((md, 'concept', fm))
98
124
 
99
- if search_type in ('all', 'note'):
125
+ if search_type in ('all', 'note') and (want('wiki') or want('chat')):
100
126
  # **`Notes/` 这个目录在本仓库里从来不存。** 2026-09-27 实测:Vault 里没有它,
101
127
  # 于是 `--type note` 一直搜的是空气——而它本该覆盖的阅读笔记有 25,676 篇,
102
128
  # 是最大的一层。笔记实际住在下面这几个目录里。
@@ -105,6 +131,9 @@ def collect_files(vault: str, biz_daily: str, search_type: str, days: int) -> li
105
131
  # `Sources/WeChat`(两万六千篇原始文章,走 biz-daily 那条路)不是一回事:
106
132
  # 这里面是一百多张**已经提炼过的**卡——会话里聊了什么、有哪些人、欠着什么。
107
133
  for sub in ('002_Literature', '001_Daily', '003_Ideas', '008_MOC', 'Sources/Chat'):
134
+ # 笔记层也分线:`Sources/Chat` 是聊天卡(聊天线),其余是用户自己的笔记(文章线)
135
+ if not want('chat' if sub in CHAT_NOTE_DIRS else 'wiki'):
136
+ continue
108
137
  notes_dir = vault_path / sub
109
138
  if not notes_dir.is_dir():
110
139
  continue
@@ -127,6 +156,8 @@ def main():
127
156
  parser.add_argument('--top-k', default='10', help='返回数量')
128
157
  parser.add_argument('--type', default='all', choices=['all', 'article', 'concept', 'note'])
129
158
  parser.add_argument('--days', type=int, default=90, help='搜索天数范围')
159
+ parser.add_argument('--line', choices=list(LINE_CHOICES), default='wiki',
160
+ help='只看一条知识库:wiki=文章线(默认)、chat=聊天线、all=两条都看')
130
161
  parser.add_argument('--vault', default=DEFAULT_VAULT, help='Vault 路径')
131
162
  parser.add_argument('--biz-daily', default=DEFAULT_BIZ, help='biz-daily 路径')
132
163
  parser.add_argument('--json', action='store_true', help='JSON 输出')
@@ -136,14 +167,19 @@ def main():
136
167
  print('请提供搜索关键词')
137
168
  sys.exit(1)
138
169
 
139
- files = collect_files(args.vault, args.biz_daily, args.type, args.days)
170
+ files = collect_files(args.vault, args.biz_daily, args.type, args.days, line=args.line)
140
171
  results = search_text(args.query, files, int(args.top_k))
141
172
 
142
173
  if args.json:
143
- print(json.dumps(results, ensure_ascii=False, indent=2))
174
+ # **带上范围**:`--line chat` 搜空了和"知识库里真没有"是两回事,而这一支的输出
175
+ # 就是 JSON 本身(CLI 直接把它打给人看),没有别的地方能写这句。
176
+ print(json.dumps({'line': args.line, 'lineLabel': line_label(args.line),
177
+ 'type': args.type, 'results': results},
178
+ ensure_ascii=False, indent=2))
144
179
  return
145
180
 
146
- print(f'🔍 搜索 "{args.query}" 找到 {len(results)} 条 (类型: {args.type}, {args.days}天内)\n')
181
+ print(f'🔍 搜索 "{args.query}" 找到 {len(results)} 条 '
182
+ f'(类型: {args.type}, 范围: {line_label(args.line)}, {args.days}天内)\n')
147
183
  for i, r in enumerate(results):
148
184
  icon = {'article': '📄', 'concept': '🧠', 'note': '📝'}.get(r['category'], '📎')
149
185
  num = str(i + 1).rjust(2)
@@ -20,8 +20,14 @@ import glob
20
20
  import hashlib
21
21
  import os
22
22
  import subprocess
23
+ import sys
23
24
  import tempfile
24
25
 
26
+ # 手跑或被重定向时 stdout 走本机编码(Windows 是 GBK):非 GBK 字符(本文件里的 `✓`)会
27
+ # UnicodeEncodeError、整次输出没了;桥接跑有 PYTHONIOENCODING=utf-8 所以看不出来。
28
+ # 这条让两条路的输出一致(同 docs/EXTENDING.md Recipe E,守它的测试是 test/script_stdout_encoding_test.py)。
29
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
30
+
25
31
  try:
26
32
  from cryptography.hazmat.primitives import padding
27
33
  from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
@@ -25,12 +25,17 @@ import sys
25
25
  from pathlib import Path
26
26
 
27
27
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
28
- from _utils import CONCEPT_DIRS, normalize_concept_name, parse_frontmatter # noqa: E402
29
-
30
- DEFAULT_PAGES_DIR = 'output/wechat-vault/Wiki/Concepts'
31
- VAULT_ROOT = 'output/wechat-vault'
32
- # 体检要看的**全部**概念页目录(Vault 相对路径那份清单在 `_utils.CONCEPT_DIRS`)。
33
- VAULT_CONCEPT_DIRS = tuple(str(Path(VAULT_ROOT) / d) for d in CONCEPT_DIRS)
28
+ from _utils import (CONCEPT_DIRS, KNOWLEDGE_LINES, is_line_qualified, line_for_concept_dir, # noqa: E402
29
+ line_label, link_target_name, normalize_concept_name, parse_frontmatter)
30
+
31
+ # 这里原来还有一份 `DEFAULT_PAGES_DIR = 'output/wechat-vault/Wiki/Concepts'`:**没人用**,
32
+ # 而它是文章线概念目录的第二份拷贝 —— 谁哪天顺手用了它,聊天线就会静默地不被体检
33
+ # ("体检通过"于是成了假话)。默认值现在只有一个来源:`--dir` 不传就是清单里的全部线。
34
+ # 文章线的根(材料目录 `002_Literature`/`Sources/WeChat` 都在它下面)。取自清单的**第一条线**,
35
+ # 不再自己写一遍字符串 —— 两条线各自一个根之后,这里会跟着清单变。
36
+ VAULT_ROOT = KNOWLEDGE_LINES[0][1]
37
+ # 体检要看的**全部**概念页目录:由清单拼出(每条线 = 根 + 线内概念目录)。
38
+ VAULT_CONCEPT_DIRS = tuple(str(Path(root) / concepts) for _line, root, concepts in KNOWLEDGE_LINES)
34
39
  # 链接可能指向的「材料」层(与卡片目录一起参与可解析性判断)
35
40
  MATERIAL_DIRS = tuple(str(Path(VAULT_ROOT) / d) for d in
36
41
  ('002_Literature', '001_Daily', 'Sources/Chat', 'Sources/WeChat'))
@@ -69,19 +74,40 @@ def links_by_section(body: str) -> list:
69
74
  才真的建了页。所以那一节里的"还没有页"是**扩张候选**,不是坏掉的东西;
70
75
  而 `## 来源` 里的 `[[<卡片>.md]]` 断了就是**真断了**(卡片被删或改名)。
71
76
  一视同仁地报"40 条死链",看的人只会以为这里烂掉了。
77
+
78
+ 名字一律经 `link_target_name` **归一化**(去掉 `|显示文本`、去掉线的路径前缀):
79
+ 跨线同名的链接写的是 `[[Chat/Concepts/DeepSeek|DeepSeek]]`,不归一化的话这些链接
80
+ 会被当成"带路径的卡片链接"报成断链——而它们其实解析得好好的。
72
81
  """
73
82
  section, out = '', []
74
83
  for line in (body or '').split('\n'):
75
84
  if line.strip().startswith('## '):
76
85
  section = line.strip()[3:].strip()
77
86
  continue
78
- for name in _LINK.findall(line):
79
- name = name.strip()
87
+ for raw in _LINK.findall(line):
88
+ name = link_target_name(raw)
80
89
  if name:
81
90
  out.append((section, name))
82
91
  return out
83
92
 
84
93
 
94
+ def raw_links_by_section(body: str) -> list:
95
+ """和 `links_by_section` 一样,但**保留原始写法**:`(小节, 原文, 归一化后的名字)`。
96
+
97
+ 体检要判"这条链接消歧了没有",而那是**原文**才有的信息(归一化之后两种写法一模一样)。
98
+ """
99
+ section, out = '', []
100
+ for line in (body or '').split('\n'):
101
+ if line.strip().startswith('## '):
102
+ section = line.strip()[3:].strip()
103
+ continue
104
+ for raw in _LINK.findall(line):
105
+ raw = raw.strip()
106
+ if raw:
107
+ out.append((section, raw, link_target_name(raw)))
108
+ return out
109
+
110
+
85
111
  def collect_card_stems(card_dirs) -> set:
86
112
  """卡片文件名(不带 `.md`)。`## 来源` 那节的链接就是这些名字。"""
87
113
  stems = set()
@@ -138,12 +164,22 @@ def inspect(pages: list, exists, card_links=()) -> dict:
138
164
  - `aspirational`:`## 相关概念` 那节里指向"还没建页"的概念——**扩张候选,不是错误**;
139
165
  - `orphans`:连卡片都没提到过它的页面(卡片链接也算入链,否则每张页都会被误报成孤儿);
140
166
  - `empty`:没有定义也没有要点;
141
- - `duplicateTitles`:同名页;
167
+ - `duplicateTitles`:**同一条线内**撞了名字的页(跨线同名不算,另见下一条);
168
+ - `crossLineSameName`:两条线都有这个名字(**设计使然,不是错误**)—— 合并视图里
169
+ 就是靠它看两条线在哪重叠;
170
+ - `ambiguousLinks`:**指向**"两条线都有"那个名字、又**没带路径**的链接 —— 在 Obsidian
171
+ 里点它会命中哪一张是不确定的。这一项**能修**(`wiki compile --qualify-links`),
172
+ 所以它和上一条不同:上一条不用改,这一条要改;
142
173
  - `nearDuplicates`:`{'sameNode': [...], 'contained': [...]}` 两档,可信度差一个量级
143
174
  —— 见 `near_duplicate_titles`。
144
175
  """
145
176
  stems = {page['stem'] for page in pages}
146
177
  inbound = {stem: 0 for stem in stems}
178
+ # 哪条线有哪些名字:下面判"跨线同名""二义链接"都要用,所以先算一次。
179
+ lines_of = {}
180
+ for page in pages:
181
+ lines_of.setdefault(page['stem'], set()).add(page.get('dir', ''))
182
+ ambiguous_names = {name for name, where in lines_of.items() if len(where) > 1}
147
183
  # **别名要归位到它那一页,否则那张页永远被报成孤儿。** 文件名是概念名的一个有损变换
148
184
  # (`:` `/ ? * " < > |` 换成 `_`,再截到 60 字符),而指向它的链接用的是**原名**:
149
185
  # `resolvable_names` 早就把别名算进"存在"了(所以断链是 0),但**入链计数**这里原来只认页码
@@ -160,9 +196,13 @@ def inspect(pages: list, exists, card_links=()) -> dict:
160
196
  for name in card_links: # 卡片 → 概念,这是主要入链
161
197
  if not (name.endswith('.md') or '/' in name) and stem_of(name) in inbound:
162
198
  inbound[stem_of(name)] += 1
163
- broken, aspirational, empty = [], [], []
199
+ broken, aspirational, empty, ambiguous_links = [], [], [], []
164
200
  for page in pages:
165
- for section, name in links_by_section(page['body']):
201
+ # 走**保留原文**的那一份:判"消歧了没有"只有原文里有信息(归一化之后两种写法一样)
202
+ for section, raw, name in raw_links_by_section(page['body']):
203
+ if name in ambiguous_names and not is_line_qualified(raw):
204
+ ambiguous_links.append({'page': page['stem'], 'dir': page.get('dir', ''),
205
+ 'target': name})
166
206
  if exists(name):
167
207
  if not (name.endswith('.md') or '/' in name) and stem_of(name) in inbound:
168
208
  inbound[stem_of(name)] += 1
@@ -177,10 +217,24 @@ def inspect(pages: list, exists, card_links=()) -> dict:
177
217
  orphans = sorted(stem for stem, count in inbound.items() if count == 0)
178
218
  titles = {}
179
219
  for page in pages:
180
- titles.setdefault(page['title'] or page['stem'], []).append(page['stem'])
181
- duplicates = {title: stems for title, stems in titles.items() if len(stems) > 1}
220
+ # **同名要按线分组判**。两条线各有一张同名页是设计使然(用户 2026-10-10 要求两个
221
+ # 概念命名空间分开),它不是"同名页"这一项要找的东西;真该报的是**同一条线里**
222
+ # 撞了名字 —— 那会让 `[[名字]]` 在那一棵目录里就没有确定目标。
223
+ titles.setdefault(page.get('dir', ''), {}).setdefault(
224
+ page['title'] or page['stem'], []).append(page['stem'])
225
+ duplicates = {}
226
+ for _where, by_title in sorted(titles.items()):
227
+ for title, stems in by_title.items():
228
+ if len(stems) > 1:
229
+ duplicates.setdefault(title, []).extend(stems)
230
+ # **跨线同名另立一节**:它不是错误,是"这两条线在哪些概念上有重叠"——合并视图里
231
+ # 唯一想看的那个数(2026-10-10 实测 36 个)。混进 `duplicateTitles` 的话,
232
+ # 读的人会去"修"一个设计如此的东西。
233
+ cross_line = sorted(ambiguous_names)
182
234
  return {'pages': len(pages), 'broken': broken, 'aspirational': aspirational,
183
235
  'orphans': orphans, 'empty': empty, 'duplicateTitles': duplicates,
236
+ 'crossLineSameName': cross_line,
237
+ 'ambiguousLinks': ambiguous_links,
184
238
  'degenerateFields': degenerate_fields(pages),
185
239
  'nearDuplicates': near_duplicate_titles(pages)}
186
240
 
@@ -227,7 +281,7 @@ def near_duplicate_titles(pages: list) -> dict:
227
281
  # **按目录分开比。** 两个概念目录是**两个知识库**(D-051),同一个名字两边各有一张页是
228
282
  # 设计使然、不是重复;`compile_wiki --merge-duplicates` 也是按目录跑的,永远动不了跨线的
229
283
  # 组。2026-09-28 实测:合并跑完后还剩 2 组(`AI 工具`、`GLORIA`)全是跨线的 —— 而报告
230
- # 写着"这些就是合并会合并的",是句假话。跨线同名另有 `duplicateTitles` 那一节在报。
284
+ # 写着"这些就是合并会合并的",是句假话。跨线同名另有 `crossLineSameName` 那一节在报。
231
285
  named = sorted({(p.get('dir', ''), p['title'] or p['stem']) for p in pages})
232
286
  same, contained = [], []
233
287
  for where in sorted({w for w, _ in named}):
@@ -393,6 +447,12 @@ def main():
393
447
  report['success'] = True
394
448
  report['pagesDir'] = existing
395
449
  report['cardLinks'] = len(card_links)
450
+ # **这一趟体检的是哪条线**:读的人(助手的 `wiki_health`)要按线分栏报,而它原来是从
451
+ # 路径里找 `Chat` 这个词猜的 —— 两条线各自一个根之后聊天线很可能不再叫 `Chat`,
452
+ # 于是它会**把聊天线报成文章线**,且不报错。所以由这一侧给出(认不出线就给 null)。
453
+ lines = [line_for_concept_dir(d) for d in existing]
454
+ report['line'] = lines[0] if len(set(lines)) == 1 else None
455
+ report['lineLabel'] = line_label(report['line'])
396
456
 
397
457
  if args.json:
398
458
  print(json.dumps(report, ensure_ascii=False, indent=2))
@@ -415,9 +475,25 @@ def main():
415
475
  print(' %s(%d 字)' % (item['page'], item['chars']))
416
476
  print(' 重新跑一次 wiki compile 通常就好了(那次模型返回不完整)')
417
477
  if report['duplicateTitles']:
418
- print('\n同名页 %d 组:' % len(report['duplicateTitles']))
478
+ print('\n同名页 %d 组(**同一条线内**撞了名字):' % len(report['duplicateTitles']))
419
479
  for title, stems in list(report['duplicateTitles'].items())[:5]:
420
480
  print(' %s ← %s' % (title, '、'.join(stems)))
481
+ if report.get('crossLineSameName'):
482
+ # 不当成问题报:两条线各留一张同名页是设计(用户要求两个命名空间分开)。
483
+ # 列出来是因为"哪些概念两条线都有"本身有信息量,而它**只能从这里看到**。
484
+ print('\n两条线都有的概念 %d 个(**设计如此,不用改**):%s'
485
+ % (len(report['crossLineSameName']), '、'.join(report['crossLineSameName'][:12])))
486
+ print(' 在 Obsidian 里这些名字的裸链接是**二义的**(官方:可能命中的不是你想要的'
487
+ '那一张)。要精确指着某一条线就用带路径的链接 `[[Wiki/Concepts/名字]]`')
488
+ if report.get('ambiguousLinks'):
489
+ rows = report['ambiguousLinks']
490
+ pages_hit = len({r['page'] for r in rows})
491
+ print('\n**二义的链接 %d 条**(指向"两条线都有"的名字、又没带路径;%d 张页):'
492
+ % (len(rows), pages_hit))
493
+ for item in rows[:5]:
494
+ print(' %s → [[%s]]' % (item['page'], item['target']))
495
+ print(' 修:`weflow-cli wiki compile --qualify-links`(本地重写、不调模型;'
496
+ '先加 `--dry-run` 看条数)')
421
497
  if report['aspirational']:
422
498
  names = sorted({item['target'] for item in report['aspirational']})
423
499
  print('\n还没建页的相关概念 %d 个(**扩张候选,不是错误**):%s'
@@ -443,8 +519,9 @@ def main():
443
519
  for field, info in report['degenerateFields'].items():
444
520
  print(' %s: %s(%s/%s 页)' % (field, info['值'], info['页数'], info.get('总页数', report['pages'])))
445
521
  print(' 多半是上游分类器把整批语料扔进了一个桶——不改分类器的话,别信这个字段')
446
- if not any((report['broken'], report['orphans'], report['empty'], report['duplicateTitles'])):
447
- print('\n没有需要修的问题(断链/孤儿/空页/同名)')
522
+ if not any((report['broken'], report['orphans'], report['empty'], report['duplicateTitles'],
523
+ report.get('ambiguousLinks'))):
524
+ print('\n没有需要修的问题(断链/孤儿/空页/同名/二义链接)')
448
525
  return 0
449
526
 
450
527
 
@@ -183,7 +183,18 @@ export class DbPathService {
183
183
  /**
184
184
  * 自动检测微信数据库根目录
185
185
  */
186
- async autoDetect(options: { searchDrives?: boolean; fullScan?: boolean } = {}): Promise<{ success: boolean; path?: string; error?: string }> {
186
+ /**
187
+ * 找一个可用的数据根。
188
+ *
189
+ * `hintedRoots` 是**调用方拿到的线索**,会被**最先**试(见 bin 里的 init:它把客户端
190
+ * 命令行自报的 `--wechat-files-path` 放进来)。为什么值得优先:下面那两条都是**按目录名猜**
191
+ * (常见路径 / 全盘扫 `xwechat_files`),而这一条是**客户端自己说的** —— 也是唯一一条
192
+ * 不受"目录被建在别处、名字还不一样"影响的来源。
193
+ *
194
+ * 线索**同样要过 `resolveDataRoot` 验证**(找到账户目录才算数):拿到一个路径 ≠ 它能用。
195
+ */
196
+ async autoDetect(options: { searchDrives?: boolean; fullScan?: boolean; hintedRoots?: string[] } = {}):
197
+ Promise<{ success: boolean; path?: string; error?: string; source?: 'client' | 'known' | 'scan' }> {
187
198
  try {
188
199
  const possiblePaths: string[] = []
189
200
  const home = homedir()
@@ -227,10 +238,17 @@ export class DbPathService {
227
238
  }
228
239
  }
229
240
 
241
+ // 1) **客户端自己说的**(命令行里的 `--wechat-files-path`)。取不到就走下面的老路。
242
+ for (const hint of options.hintedRoots ?? []) {
243
+ const dataRoot = this.resolveDataRoot(hint)
244
+ if (dataRoot) return { success: true, path: dataRoot, source: 'client' }
245
+ }
246
+
247
+ // 2) 常见路径(含按目录名在几个已知位置里找)
230
248
  for (const path of possiblePaths) {
231
249
  const dataRoot = this.resolveDataRoot(path)
232
250
  if (dataRoot) {
233
- return { success: true, path: dataRoot }
251
+ return { success: true, path: dataRoot, source: 'known' }
234
252
  }
235
253
  }
236
254
 
@@ -238,7 +256,7 @@ export class DbPathService {
238
256
  for (const volumeRoot of this.getVolumeRoots()) {
239
257
  for (const dataRootPath of this.findKnownDataRoots(volumeRoot, 12)) {
240
258
  const dataRoot = this.resolveDataRoot(dataRootPath)
241
- if (dataRoot) return { success: true, path: dataRoot }
259
+ if (dataRoot) return { success: true, path: dataRoot, source: 'scan' }
242
260
  }
243
261
  }
244
262
  }
@@ -246,7 +264,7 @@ export class DbPathService {
246
264
  if (options.fullScan) {
247
265
  for (const volumeRoot of this.getVolumeRoots()) {
248
266
  const accountPath = this.findAccountDescendant(volumeRoot, 32)
249
- if (accountPath) return { success: true, path: dirname(accountPath) }
267
+ if (accountPath) return { success: true, path: dirname(accountPath), source: 'scan' }
250
268
  }
251
269
  }
252
270
 
@@ -77,6 +77,29 @@ const STATIC_FILES: Record<string, string> = {
77
77
  // 它的左边**必须是一条笔直的切边**(内容贴着画布 x=0),贴上去才跟屏幕边严丝合缝——
78
78
  // 所以半隐时球的 `border-radius` 也要去掉(圆会把那条切边剪成弧)。
79
79
  '/panel/mascot-peek.png': 'mascot-peek.png',
80
+ // 被拎起来那套动作的 12 张(2026-10-02):6 帧拎起来 + 6 帧落回去。
81
+ // 静止帧就是 `mascot-base.png`,不新增。同上:**漏一张,那一段动作里球会闪成空图**,
82
+ // 而 PANEL_ASSET_MISSING 只写在响应体里、页面上看不到。
83
+ '/panel/mascot-lift-up-1.png': 'mascot-lift-up-1.png',
84
+ '/panel/mascot-lift-up-2.png': 'mascot-lift-up-2.png',
85
+ '/panel/mascot-lift-up-3.png': 'mascot-lift-up-3.png',
86
+ '/panel/mascot-lift-up-4.png': 'mascot-lift-up-4.png',
87
+ '/panel/mascot-lift-up-5.png': 'mascot-lift-up-5.png',
88
+ '/panel/mascot-lift-up-6.png': 'mascot-lift-up-6.png',
89
+ '/panel/mascot-lift-down-1.png': 'mascot-lift-down-1.png',
90
+ '/panel/mascot-lift-down-2.png': 'mascot-lift-down-2.png',
91
+ '/panel/mascot-lift-down-3.png': 'mascot-lift-down-3.png',
92
+ '/panel/mascot-lift-down-4.png': 'mascot-lift-down-4.png',
93
+ '/panel/mascot-lift-down-5.png': 'mascot-lift-down-5.png',
94
+ '/panel/mascot-lift-down-6.png': 'mascot-lift-down-6.png',
95
+ // 眨眼那 2 帧(2026-10-04):眼睛以外与静止帧逐像素相同,只把眼睛带贴回来。
96
+ '/panel/mascot-blink-1.png': 'mascot-blink-1.png',
97
+ '/panel/mascot-blink-2.png': 'mascot-blink-2.png',
98
+ // 挠痒痒那 4 张(2026-10-02):鼠标在球身上晃过时循环的姿势帧。同上:漏一张就闪空图。
99
+ '/panel/mascot-tickle-1.png': 'mascot-tickle-1.png',
100
+ '/panel/mascot-tickle-2.png': 'mascot-tickle-2.png',
101
+ '/panel/mascot-tickle-3.png': 'mascot-tickle-3.png',
102
+ '/panel/mascot-tickle-4.png': 'mascot-tickle-4.png',
80
103
  }
81
104
  const CONTENT_TYPES: Record<string, string> = {
82
105
  '.html': 'text/html; charset=utf-8',
@@ -318,6 +341,16 @@ export async function startPanelServer(options: PanelServerOptions): Promise<Pan
318
341
  return
319
342
  }
320
343
 
344
+ if (url.pathname === '/api/memory') {
345
+ // **只读**,而且这份数据**不经过模型**:它是给用户在面板里审计"你记住了什么"用的,
346
+ // 与 `buildSystemPrompt` 里注入的那份上下文是两件事(各有各的预算与脱敏)。
347
+ // 桶由服务端决定(上面那个 `memoryBucket`),**不接受客户端指定** —— 否则这就是一个
348
+ // 能读别的记忆桶的口子。
349
+ if (req.method !== 'GET') { json(res, 405, { ok: false, code: 'METHOD_NOT_ALLOWED' }); return }
350
+ json(res, 200, { ok: true, bucket: memoryBucket, ...service.memorySnapshot(memoryBucket) })
351
+ return
352
+ }
353
+
321
354
  if (url.pathname === '/api/ask') {
322
355
  if (req.method !== 'POST') { json(res, 405, { ok: false, code: 'METHOD_NOT_ALLOWED' }); return }
323
356
  const contentType = String(req.headers['content-type'] ?? '')
@@ -955,6 +955,32 @@ export class AssistantService {
955
955
  return { used: this.dailyCount, limit: DAILY_LIMIT }
956
956
  }
957
957
 
958
+ /**
959
+ * 面板里那个"记忆"按钮要的:**它记住了什么**。只读,且**不经过模型**。
960
+ *
961
+ * 为什么不复用微信里那条 `记忆` 指令的文本:聊天那条路给的是"最近 5 条"的摘要(那是给对话用的),
962
+ * 面板要按时间列全、还要看得出每条是从哪句话来的,所以带上 `sourceQuote`。两边读的是同一份 memory,
963
+ * 只是呈现不同 —— 不各存一份。
964
+ *
965
+ * 桶(`userId`)由**调用方**决定:面板那条路传的是服务端自己那个 `memoryBucket`,不接受客户端指定。
966
+ */
967
+ memorySnapshot(userId: string): {
968
+ facts: { content: string; ts: number; sourceQuote: string }[]
969
+ summary: string
970
+ workingTurns: number
971
+ saveError: string
972
+ } {
973
+ return {
974
+ facts: this.memory.facts(userId).map(f => ({
975
+ content: f.content, ts: f.ts, sourceQuote: f.sourceQuote ?? '',
976
+ })),
977
+ summary: this.memory.summary(userId),
978
+ workingTurns: this.memory.workingWindow(userId).length,
979
+ // 落盘失败要传出去:用户问"你记住了什么"时,若上次根本没存上,那份答案就是假的
980
+ saveError: this.memory.lastSaveError ?? '',
981
+ }
982
+ }
983
+
958
984
  stop(): void {
959
985
  this.running = false
960
986
  this.svc?.stop()