weflow-cli 1.8.2 → 1.10.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 (185) hide show
  1. package/ARCHITECTURE.md +62 -26
  2. package/CHANGELOG.md +1290 -0
  3. package/OPERATIONS.md +144 -11
  4. package/README.en.md +20 -4
  5. package/README.md +30 -5
  6. package/bin/weflow-cli-electron.cjs +2 -0
  7. package/bin/weflow-cli.ts +996 -118
  8. package/dist/bin/weflow-cli.js +1038 -146
  9. package/dist/bin/weflow-cli.js.map +1 -1
  10. package/dist/mcp-server/http.d.ts +14 -0
  11. package/dist/mcp-server/http.d.ts.map +1 -0
  12. package/dist/mcp-server/http.js +84 -0
  13. package/dist/mcp-server/http.js.map +1 -0
  14. package/dist/mcp-server/httpConfig.d.ts +50 -0
  15. package/dist/mcp-server/httpConfig.d.ts.map +1 -0
  16. package/dist/mcp-server/httpConfig.js +104 -0
  17. package/dist/mcp-server/httpConfig.js.map +1 -0
  18. package/dist/mcp-server/index.d.ts +13 -1
  19. package/dist/mcp-server/index.d.ts.map +1 -1
  20. package/dist/mcp-server/index.js +58 -374
  21. package/dist/mcp-server/index.js.map +1 -1
  22. package/dist/src/core/dbPathService.d.ts +12 -0
  23. package/dist/src/core/dbPathService.d.ts.map +1 -1
  24. package/dist/src/core/dbPathService.js +20 -3
  25. package/dist/src/core/dbPathService.js.map +1 -1
  26. package/dist/src/panel/server.d.ts.map +1 -1
  27. package/dist/src/panel/server.js +61 -1
  28. package/dist/src/panel/server.js.map +1 -1
  29. package/dist/src/services/assistantEval.d.ts +69 -0
  30. package/dist/src/services/assistantEval.d.ts.map +1 -1
  31. package/dist/src/services/assistantEval.js +270 -8
  32. package/dist/src/services/assistantEval.js.map +1 -1
  33. package/dist/src/services/assistantScenes.d.ts +81 -0
  34. package/dist/src/services/assistantScenes.d.ts.map +1 -0
  35. package/dist/src/services/assistantScenes.js +322 -0
  36. package/dist/src/services/assistantScenes.js.map +1 -0
  37. package/dist/src/services/assistantService.d.ts +33 -4
  38. package/dist/src/services/assistantService.d.ts.map +1 -1
  39. package/dist/src/services/assistantService.js +102 -6
  40. package/dist/src/services/assistantService.js.map +1 -1
  41. package/dist/src/services/assistantSkills.d.ts +94 -0
  42. package/dist/src/services/assistantSkills.d.ts.map +1 -0
  43. package/dist/src/services/assistantSkills.js +330 -0
  44. package/dist/src/services/assistantSkills.js.map +1 -0
  45. package/dist/src/services/assistantTools.d.ts +34 -1
  46. package/dist/src/services/assistantTools.d.ts.map +1 -1
  47. package/dist/src/services/assistantTools.js +931 -88
  48. package/dist/src/services/assistantTools.js.map +1 -1
  49. package/dist/src/services/assistantTrace.d.ts +4 -0
  50. package/dist/src/services/assistantTrace.d.ts.map +1 -1
  51. package/dist/src/services/assistantTrace.js +9 -0
  52. package/dist/src/services/assistantTrace.js.map +1 -1
  53. package/dist/src/services/configService.d.ts +9 -0
  54. package/dist/src/services/configService.d.ts.map +1 -1
  55. package/dist/src/services/configService.js +5 -2
  56. package/dist/src/services/configService.js.map +1 -1
  57. package/dist/src/services/messageQuery.d.ts +18 -0
  58. package/dist/src/services/messageQuery.d.ts.map +1 -1
  59. package/dist/src/services/messageQuery.js +29 -0
  60. package/dist/src/services/messageQuery.js.map +1 -1
  61. package/dist/src/services/outboundSend.d.ts +53 -0
  62. package/dist/src/services/outboundSend.d.ts.map +1 -0
  63. package/dist/src/services/outboundSend.js +90 -0
  64. package/dist/src/services/outboundSend.js.map +1 -0
  65. package/dist/src/services/pythonBridge.d.ts.map +1 -1
  66. package/dist/src/services/pythonBridge.js +1 -0
  67. package/dist/src/services/pythonBridge.js.map +1 -1
  68. package/dist/src/services/wechatClient.d.ts +125 -0
  69. package/dist/src/services/wechatClient.d.ts.map +1 -0
  70. package/dist/src/services/wechatClient.js +344 -0
  71. package/dist/src/services/wechatClient.js.map +1 -0
  72. package/dist/src/services/wechatMedia.d.ts +44 -0
  73. package/dist/src/services/wechatMedia.d.ts.map +1 -0
  74. package/dist/src/services/wechatMedia.js +155 -0
  75. package/dist/src/services/wechatMedia.js.map +1 -0
  76. package/dist/src/services/wechatMessageService.d.ts +11 -0
  77. package/dist/src/services/wechatMessageService.d.ts.map +1 -1
  78. package/dist/src/services/wechatMessageService.js +55 -11
  79. package/dist/src/services/wechatMessageService.js.map +1 -1
  80. package/dist/src/types.d.ts +4 -0
  81. package/dist/src/types.d.ts.map +1 -1
  82. package/dist/src/utils/python.js +2 -2
  83. package/dist/src/utils/python.js.map +1 -1
  84. package/docs/BRANCHES.md +37 -0
  85. package/docs/CONTACT_DB_SCHEMA.md +1085 -0
  86. package/docs/DECISIONS.md +1624 -0
  87. package/docs/EXTENDING.md +98 -10
  88. package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +6 -0
  89. package/docs/HEALTH-CHECK.md +12 -7
  90. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +2 -2
  91. package/docs/MCP.md +58 -4
  92. package/docs/PROJECT_STATE.md +180 -11
  93. package/docs/RELEASING.md +115 -1
  94. package/docs/images/weflow-architecture-gpt-image-2.png +0 -0
  95. package/logo.png +0 -0
  96. package/mcp-server/http.ts +108 -0
  97. package/mcp-server/httpConfig.ts +126 -0
  98. package/mcp-server/index.ts +359 -681
  99. package/package.json +5 -3
  100. package/resources/js/graph3d/NOTICE.txt +35 -0
  101. package/resources/js/graph3d/d3-binarytree.min.js +2 -0
  102. package/resources/js/graph3d/d3-dispatch.min.js +2 -0
  103. package/resources/js/graph3d/d3-force-3d.min.js +2 -0
  104. package/resources/js/graph3d/d3-octree.min.js +2 -0
  105. package/resources/js/graph3d/d3-quadtree.min.js +2 -0
  106. package/resources/js/graph3d/d3-timer.min.js +2 -0
  107. package/resources/js/graph3d/layout.mjs +63 -0
  108. package/resources/js/graph3d/three.min.js +6 -0
  109. package/resources/js/graph3d/viewer2d.js +448 -0
  110. package/resources/panel/ball-position.cjs +48 -2
  111. package/resources/panel/index.html +18 -4
  112. package/resources/panel/main.cjs +137 -5
  113. package/resources/panel/mascot-base.png +0 -0
  114. package/resources/panel/mascot-blink-1.png +0 -0
  115. package/resources/panel/mascot-blink-2.png +0 -0
  116. package/resources/panel/mascot-iris.png +0 -0
  117. package/resources/panel/mascot-lift-down-1.png +0 -0
  118. package/resources/panel/mascot-lift-down-2.png +0 -0
  119. package/resources/panel/mascot-lift-down-3.png +0 -0
  120. package/resources/panel/mascot-lift-down-4.png +0 -0
  121. package/resources/panel/mascot-lift-down-5.png +0 -0
  122. package/resources/panel/mascot-lift-down-6.png +0 -0
  123. package/resources/panel/mascot-lift-up-1.png +0 -0
  124. package/resources/panel/mascot-lift-up-2.png +0 -0
  125. package/resources/panel/mascot-lift-up-3.png +0 -0
  126. package/resources/panel/mascot-lift-up-4.png +0 -0
  127. package/resources/panel/mascot-lift-up-5.png +0 -0
  128. package/resources/panel/mascot-lift-up-6.png +0 -0
  129. package/resources/panel/mascot-peek.png +0 -0
  130. package/resources/panel/mascot-tickle-1.png +0 -0
  131. package/resources/panel/mascot-tickle-2.png +0 -0
  132. package/resources/panel/mascot-tickle-3.png +0 -0
  133. package/resources/panel/mascot-tickle-4.png +0 -0
  134. package/resources/panel/panel.css +205 -59
  135. package/resources/panel/preload.cjs +26 -0
  136. package/resources/panel/renderer.js +646 -17
  137. package/scripts/_utils.py +1203 -931
  138. package/scripts/backfill_articles.py +202 -21
  139. package/scripts/biz_daily.py +26 -34
  140. package/scripts/bonds.py +331 -0
  141. package/scripts/chat_notes.py +561 -470
  142. package/scripts/chat_stats.py +8 -7
  143. package/scripts/check_readme_commands.py +426 -0
  144. package/scripts/compile_wiki.py +187 -59
  145. package/scripts/contact_schema.py +339 -0
  146. package/scripts/data/wechat_emoji.json +225 -0
  147. package/scripts/export_chat_html.py +3 -1
  148. package/scripts/fav_notes.py +4 -1
  149. package/scripts/graph_2d.py +151 -0
  150. package/scripts/graph_3d.py +646 -0
  151. package/scripts/health_check.py +163 -26
  152. package/scripts/mcp_bridge.py +6 -7
  153. package/scripts/nt_decrypt.py +3 -3
  154. package/scripts/panel_frames.py +683 -0
  155. package/scripts/pipeline.py +21 -10
  156. package/scripts/quality_eval.py +5 -0
  157. package/scripts/rag_chat.py +33 -16
  158. package/scripts/reply_debt.py +26 -6
  159. package/scripts/semantic_search.py +130 -22
  160. package/scripts/vault_rag.py +42 -16
  161. package/scripts/vault_search.py +44 -8
  162. package/scripts/wechat_emoticon.py +6 -0
  163. package/scripts/wechat_voice.py +9 -0
  164. package/scripts/wiki_lint.py +124 -24
  165. package/src/core/dbPathService.ts +22 -4
  166. package/src/panel/server.ts +58 -1
  167. package/src/services/assistantEval.ts +323 -12
  168. package/src/services/assistantScenes.ts +342 -0
  169. package/src/services/assistantService.ts +107 -6
  170. package/src/services/assistantSkills.ts +364 -0
  171. package/src/services/assistantTools.ts +2452 -1640
  172. package/src/services/assistantTrace.ts +9 -0
  173. package/src/services/configService.ts +415 -403
  174. package/src/services/messageQuery.ts +29 -0
  175. package/src/services/outboundSend.ts +126 -0
  176. package/src/services/pythonBridge.ts +1 -0
  177. package/src/services/wechatClient.ts +380 -0
  178. package/src/services/wechatMedia.ts +166 -0
  179. package/src/services/wechatMessageService.ts +62 -11
  180. package/src/types.ts +4 -0
  181. package/src/utils/python.ts +2 -2
  182. package/dist/src/utils/wechatEmoji.d.ts +0 -5
  183. package/dist/src/utils/wechatEmoji.d.ts.map +0 -1
  184. package/dist/src/utils/wechatEmoji.js +0 -35
  185. package/dist/src/utils/wechatEmoji.js.map +0 -1
@@ -16,17 +16,35 @@ DEFAULT_VAULT = os.path.join(PROJECT_ROOT, 'output', 'wechat-vault')
16
16
  DEFAULT_BIZ = os.path.join(PROJECT_ROOT, 'output', 'biz-daily')
17
17
 
18
18
  sys.path.insert(0, SCRIPTS_DIR)
19
- from _utils import CONCEPT_DIRS, call_deepseek
19
+ from _utils import CONCEPT_DIRS, LINE_LABELS, call_deepseek, line_for_relative_concepts
20
20
 
21
+ LINE_CHOICES = ('all', 'wiki', 'chat')
21
22
 
22
- def collect_context(vault: str, biz_daily: str, question: str, top_k: int) -> list[dict]:
23
- """从 Vault + biz-daily 检索相关上下文。"""
23
+
24
+ def line_label(line: str) -> str:
25
+ return '两条线' if line == 'all' else LINE_LABELS.get(line, line)
26
+
27
+
28
+ def collect_context(vault: str, biz_daily: str, question: str, top_k: int,
29
+ line: str = 'all') -> list[dict]:
30
+ """从 Vault + biz-daily 检索相关上下文。
31
+
32
+ `line` 把**材料也分开**:概念页按目录分线,而用户的笔记层与日报文章都属于**文章线**
33
+ (`--line chat` 只给聊天线的概念页)。不这么分的话,"只看聊天知识库"仍然会从阅读笔记里
34
+ 捞结果进来——那正是"两条线分开"要避免的事。
35
+
36
+ **函数默认 `all`(不缩小范围)**,用户看到的默认(文章线)在 CLI 那一层:忘了传 `line`
37
+ 的调用方会多搜一条线,而不是静默少搜一半。
38
+ """
24
39
  results = []
25
40
  search_terms = question.lower().split()
41
+ want = (lambda which: line == 'all' or line == which)
26
42
 
27
- # 1. 概念页(权重最高)——**两个目录都要读**:文章知识库与聊天知识库是分开的
43
+ # 1. 概念页(权重最高)——不传 `--line` 时两个目录都读:文章知识库与聊天知识库是分开的
28
44
  # (`_utils.CONCEPT_DIRS`),只读一个的话分出去的那一半会静默地搜不到。
29
45
  for relative in CONCEPT_DIRS:
46
+ if not want(line_for_relative_concepts(relative)):
47
+ continue
30
48
  concepts_dir = Path(vault) / relative
31
49
  if not concepts_dir.is_dir():
32
50
  continue
@@ -41,7 +59,8 @@ def collect_context(vault: str, biz_daily: str, question: str, top_k: int) -> li
41
59
 
42
60
  # 2. 阅读笔记 —— **`Notes/` 这个目录在本仓库里从来不存**(2026-09-27 实测:
43
61
  # Vault 里没有它),所以这一段一直搜的是空气。笔记实际住在 `002_Literature`。
44
- for relative in ('002_Literature', '003_Ideas', '008_MOC'):
62
+ # 这是**用户自己的**层(文章线),所以 `--line chat` 时整段跳过。
63
+ for relative in (('002_Literature', '003_Ideas', '008_MOC') if want('wiki') else ()):
45
64
  notes_dir = Path(vault) / relative
46
65
  if not notes_dir.is_dir():
47
66
  continue
@@ -57,9 +76,9 @@ def collect_context(vault: str, biz_daily: str, question: str, top_k: int) -> li
57
76
  if score > 0:
58
77
  results.append({'source': 'note', 'title': md.stem[:60], 'content': text, 'score': score})
59
78
 
60
- # 3. 最新文章
61
- biz_path = Path(biz_daily)
62
- for date_dir in sorted(biz_path.glob('20*'), reverse=True)[:7]: # 最近 7 天
79
+ # 3. 最新文章(日报 = 文章线,`--line chat` 时跳过)
80
+ biz_path = Path(biz_daily) if want('wiki') else None
81
+ for date_dir in (sorted(biz_path.glob('20*'), reverse=True)[:7] if biz_path else []): # 最近 7 天
63
82
  for md in date_dir.rglob('*.md'):
64
83
  if md.name == 'README.md' or 'README' in md.name:
65
84
  continue
@@ -75,7 +94,7 @@ def collect_context(vault: str, biz_daily: str, question: str, top_k: int) -> li
75
94
  return results[:top_k]
76
95
 
77
96
 
78
- def build_prompt(question: str, context: list[dict]) -> str:
97
+ def build_prompt(question: str, context: list[dict], line: str = 'all') -> str:
79
98
  sources_text = ''
80
99
  for i, ctx in enumerate(context):
81
100
  label = {'concept': '概念', 'note': '笔记', 'article': '文章'}.get(ctx['source'], ctx['source'])
@@ -83,6 +102,9 @@ def build_prompt(question: str, context: list[dict]) -> str:
83
102
 
84
103
  return f'''你是个人知识助手,基于用户笔记库回答问题。
85
104
 
105
+ **这一趟只检索了:{line_label(line)}**(另外的范围里可能有相关内容)。没有段落时按
106
+ "**这个范围里**没有相关信息"说,别说成"知识库里没有"。
107
+
86
108
  知识库内容:
87
109
  {sources_text}
88
110
 
@@ -99,6 +121,8 @@ def main():
99
121
  parser.add_argument('--vault', default=DEFAULT_VAULT, help='Vault 路径')
100
122
  parser.add_argument('--biz-daily', default=DEFAULT_BIZ, help='biz-daily 路径')
101
123
  parser.add_argument('--api-key', default='', help='DeepSeek API key')
124
+ parser.add_argument('--line', choices=list(LINE_CHOICES), default='wiki',
125
+ help='只看一条知识库:wiki=文章线(默认)、chat=聊天线、all=两条都看')
102
126
  parser.add_argument('--json', action='store_true', help='JSON 输出')
103
127
  args = parser.parse_args()
104
128
 
@@ -113,21 +137,23 @@ def main():
113
137
  sys.exit(1)
114
138
 
115
139
  top_k = int(args.top_k)
116
- context = collect_context(args.vault, args.biz_daily, question, top_k)
140
+ context = collect_context(args.vault, args.biz_daily, question, top_k, line=args.line)
117
141
 
118
142
  if args.json:
119
- output = {'question': question, 'sources': [{'source': c['source'], 'title': c['title'], 'score': c['score']} for c in context]}
143
+ output = {'question': question, 'line': args.line, 'lineLabel': line_label(args.line),
144
+ 'sources': [{'source': c['source'], 'title': c['title'], 'score': c['score']} for c in context]}
120
145
  if context:
121
- prompt = build_prompt(question, context)
146
+ prompt = build_prompt(question, context, args.line)
122
147
  output['answer'] = call_deepseek(prompt, api_key, max_tokens=800)
123
148
  else:
124
- output['answer'] = '知识库中没有相关信息'
149
+ # **说清是哪个范围里没有**:`知识库中没有相关信息` 会被读成"整库都没有"
150
+ output['answer'] = '%s里没有相关信息' % line_label(args.line)
125
151
  print(json.dumps(output, ensure_ascii=False, indent=2))
126
152
  return
127
153
 
128
- print(f'🔍 "{question}"\n')
154
+ print(f'🔍 "{question}"(范围:{line_label(args.line)})\n')
129
155
  if not context:
130
- print('未找到相关内容')
156
+ print('%s里没有相关信息(`--line all` 两条一起看)' % line_label(args.line))
131
157
  return
132
158
 
133
159
  print(f'检索到 {len(context)} 条相关内容:\n')
@@ -135,7 +161,7 @@ def main():
135
161
  icon = {'concept': '🧠', 'note': '📝', 'article': '📄'}.get(c['source'], '📎')
136
162
  print(f' {i+1}. {icon} [{c["source"]}] {c["title"]}')
137
163
 
138
- prompt = build_prompt(question, context)
164
+ prompt = build_prompt(question, context, args.line)
139
165
  print(f'\n{"="*50}')
140
166
  answer = call_deepseek(prompt, api_key, max_tokens=800)
141
167
  print(answer)
@@ -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
@@ -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
@@ -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,21 +164,48 @@ 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}
183
+ # **别名要归位到它那一页,否则那张页永远被报成孤儿。** 文件名是概念名的一个有损变换
184
+ # (`:` `/ ? * " < > |` 换成 `_`,再截到 60 字符),而指向它的链接用的是**原名**:
185
+ # `resolvable_names` 早就把别名算进"存在"了(所以断链是 0),但**入链计数**这里原来只认页码
186
+ # —— 名字对不上,卡片那条链接既不计入链也不报断链,那一页于是在图里是孤岛。
187
+ # (2026-10-01 实测:`[[Qwen3.5:9B]]` → `Qwen3.5_9B.md`,49,953 页里有 28 张。)
188
+ alias_to_stem = {str(alias): page['stem']
189
+ for page in pages
190
+ for alias in (page.get('aliases') or [])
191
+ if str(alias) not in stems}
192
+
193
+ def stem_of(name: str) -> str:
194
+ return alias_to_stem.get(name, name)
195
+
147
196
  for name in card_links: # 卡片 → 概念,这是主要入链
148
- if not (name.endswith('.md') or '/' in name) and name in inbound:
149
- inbound[name] += 1
150
- broken, aspirational, empty = [], [], []
197
+ if not (name.endswith('.md') or '/' in name) and stem_of(name) in inbound:
198
+ inbound[stem_of(name)] += 1
199
+ broken, aspirational, empty, ambiguous_links = [], [], [], []
151
200
  for page in pages:
152
- 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})
153
206
  if exists(name):
154
- if not (name.endswith('.md') or '/' in name) and name in inbound:
155
- inbound[name] += 1
207
+ if not (name.endswith('.md') or '/' in name) and stem_of(name) in inbound:
208
+ inbound[stem_of(name)] += 1
156
209
  continue
157
210
  entry = {'page': page['stem'], 'target': name, 'section': section}
158
211
  if any(section.startswith(prefix) for prefix in RELATED_SECTIONS):
@@ -164,10 +217,24 @@ def inspect(pages: list, exists, card_links=()) -> dict:
164
217
  orphans = sorted(stem for stem, count in inbound.items() if count == 0)
165
218
  titles = {}
166
219
  for page in pages:
167
- titles.setdefault(page['title'] or page['stem'], []).append(page['stem'])
168
- 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)
169
234
  return {'pages': len(pages), 'broken': broken, 'aspirational': aspirational,
170
235
  'orphans': orphans, 'empty': empty, 'duplicateTitles': duplicates,
236
+ 'crossLineSameName': cross_line,
237
+ 'ambiguousLinks': ambiguous_links,
171
238
  'degenerateFields': degenerate_fields(pages),
172
239
  'nearDuplicates': near_duplicate_titles(pages)}
173
240
 
@@ -214,7 +281,7 @@ def near_duplicate_titles(pages: list) -> dict:
214
281
  # **按目录分开比。** 两个概念目录是**两个知识库**(D-051),同一个名字两边各有一张页是
215
282
  # 设计使然、不是重复;`compile_wiki --merge-duplicates` 也是按目录跑的,永远动不了跨线的
216
283
  # 组。2026-09-28 实测:合并跑完后还剩 2 组(`AI 工具`、`GLORIA`)全是跨线的 —— 而报告
217
- # 写着"这些就是合并会合并的",是句假话。跨线同名另有 `duplicateTitles` 那一节在报。
284
+ # 写着"这些就是合并会合并的",是句假话。跨线同名另有 `crossLineSameName` 那一节在报。
218
285
  named = sorted({(p.get('dir', ''), p['title'] or p['stem']) for p in pages})
219
286
  same, contained = [], []
220
287
  for where in sorted({w for w, _ in named}):
@@ -326,12 +393,16 @@ def collect(pages_dir: str, card_dirs) -> list:
326
393
  # 判重按目录分开比(两个目录是两个知识库),所以要记住这张页住哪儿
327
394
  'dir': str(pages_dir),
328
395
  'topic': compile_frontmatter_topic(frontmatter),
329
- 'title': str(frontmatter.get('title') or '').strip('"'),
396
+ # `parse_frontmatter` 已经把外层引号拆掉了,**这里不能再剥一次**:
397
+ # `strip('"')` 会连名字**自己末尾**的引号一起吃掉,`AI 长出"手脚"` 变成
398
+ # `AI 长出"手脚` —— 然后它与卡片里那条链接差一个字符,永远匹配不上,
399
+ # 那页就被报成孤儿(2026-10-01 实测,49,953 页里 1 张)。
400
+ 'title': str(frontmatter.get('title') or ''),
330
401
  # **别名也算"指向这一页的名字"**:`--merge-duplicates` 把
331
402
  # `GPT-5.6` 并进 `GPT 5.6` 时就是靠 aliases 让旧链接不断的,
332
403
  # Obsidian 认它。体检不认的话,会把这些名字报成"还没建页、
333
404
  # 建议再跑 compile" —— 而它们已经有页了。
334
- 'aliases': [str(a).strip().strip('"').strip("'") for a in raw_aliases],
405
+ 'aliases': [str(a).strip() for a in raw_aliases],
335
406
  'links': extract_links(body),
336
407
  'body': body})
337
408
  return pages
@@ -364,12 +435,24 @@ def main():
364
435
  # 只查卡片目录会把这类全报成断链——实测 1,950 条,而它们在 Obsidian 里都是好的
365
436
  # (两边文件名本来就不同:卡片保留完整标题,阅读笔记截到 50 字)。
366
437
  card_stems = collect_card_stems(tuple(CARD_DIRS) + MATERIAL_DIRS)
438
+ resolvable = resolvable_names(pages) # 只算一次(见下面那条注释)
367
439
  report = inspect(pages,
368
- lambda name: resolve(name, resolvable_names(pages), CARD_DIRS, card_stems),
440
+
441
+ # **注意别把它写回 lambda 里**:闭包体是**调用时**求值的,
442
+ # 写在里面等于每条链接都重建一次全部页名的集合 —— 实测文章线
443
+ # (3,623 页)因此要 9 分 29 秒,聊天线(1,527 页)只要 3.2 秒。
444
+ # 提出来算一次之后是同一条线的秒级。
445
+ lambda name: resolve(name, resolvable, CARD_DIRS, card_stems),
369
446
  card_links)
370
447
  report['success'] = True
371
448
  report['pagesDir'] = existing
372
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'])
373
456
 
374
457
  if args.json:
375
458
  print(json.dumps(report, ensure_ascii=False, indent=2))
@@ -392,9 +475,25 @@ def main():
392
475
  print(' %s(%d 字)' % (item['page'], item['chars']))
393
476
  print(' 重新跑一次 wiki compile 通常就好了(那次模型返回不完整)')
394
477
  if report['duplicateTitles']:
395
- print('\n同名页 %d 组:' % len(report['duplicateTitles']))
478
+ print('\n同名页 %d 组(**同一条线内**撞了名字):' % len(report['duplicateTitles']))
396
479
  for title, stems in list(report['duplicateTitles'].items())[:5]:
397
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` 看条数)')
398
497
  if report['aspirational']:
399
498
  names = sorted({item['target'] for item in report['aspirational']})
400
499
  print('\n还没建页的相关概念 %d 个(**扩张候选,不是错误**):%s'
@@ -420,8 +519,9 @@ def main():
420
519
  for field, info in report['degenerateFields'].items():
421
520
  print(' %s: %s(%s/%s 页)' % (field, info['值'], info['页数'], info.get('总页数', report['pages'])))
422
521
  print(' 多半是上游分类器把整批语料扔进了一个桶——不改分类器的话,别信这个字段')
423
- if not any((report['broken'], report['orphans'], report['empty'], report['duplicateTitles'])):
424
- print('\n没有需要修的问题(断链/孤儿/空页/同名)')
522
+ if not any((report['broken'], report['orphans'], report['empty'], report['duplicateTitles'],
523
+ report.get('ambiguousLinks'))):
524
+ print('\n没有需要修的问题(断链/孤儿/空页/同名/二义链接)')
425
525
  return 0
426
526
 
427
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