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.
Files changed (205) hide show
  1. package/ARCHITECTURE.md +3 -2
  2. package/CHANGELOG.md +1966 -0
  3. package/OPERATIONS.md +808 -0
  4. package/README.en.md +93 -7
  5. package/README.md +31 -6
  6. package/bin/weflow-cli.ts +1245 -26
  7. package/dist/bin/weflow-cli.js +1349 -72
  8. package/dist/bin/weflow-cli.js.map +1 -1
  9. package/dist/mcp-server/index.js +39 -13
  10. package/dist/mcp-server/index.js.map +1 -1
  11. package/dist/src/core/appMsgFormat.d.ts +37 -0
  12. package/dist/src/core/appMsgFormat.d.ts.map +1 -0
  13. package/dist/src/core/appMsgFormat.js +106 -0
  14. package/dist/src/core/appMsgFormat.js.map +1 -0
  15. package/dist/src/core/ntCore.d.ts +43 -0
  16. package/dist/src/core/ntCore.d.ts.map +1 -1
  17. package/dist/src/core/ntCore.js +31 -0
  18. package/dist/src/core/ntCore.js.map +1 -1
  19. package/dist/src/core/sqlcipherCore.d.ts +0 -12
  20. package/dist/src/core/sqlcipherCore.d.ts.map +1 -1
  21. package/dist/src/core/sqlcipherCore.js +5 -64
  22. package/dist/src/core/sqlcipherCore.js.map +1 -1
  23. package/dist/src/core/wcdbCore.d.ts +1 -1
  24. package/dist/src/core/wcdbCore.d.ts.map +1 -1
  25. package/dist/src/core/wcdbCore.js +5 -1
  26. package/dist/src/core/wcdbCore.js.map +1 -1
  27. package/dist/src/panel/client.d.ts +25 -0
  28. package/dist/src/panel/client.d.ts.map +1 -0
  29. package/dist/src/panel/client.js +34 -0
  30. package/dist/src/panel/client.js.map +1 -0
  31. package/dist/src/panel/endpoint.d.ts +45 -0
  32. package/dist/src/panel/endpoint.d.ts.map +1 -0
  33. package/dist/src/panel/endpoint.js +132 -0
  34. package/dist/src/panel/endpoint.js.map +1 -0
  35. package/dist/src/panel/launch.d.ts +50 -0
  36. package/dist/src/panel/launch.d.ts.map +1 -0
  37. package/dist/src/panel/launch.js +74 -0
  38. package/dist/src/panel/launch.js.map +1 -0
  39. package/dist/src/panel/server.d.ts +23 -0
  40. package/dist/src/panel/server.d.ts.map +1 -0
  41. package/dist/src/panel/server.js +413 -0
  42. package/dist/src/panel/server.js.map +1 -0
  43. package/dist/src/panel/userId.d.ts +37 -0
  44. package/dist/src/panel/userId.d.ts.map +1 -0
  45. package/dist/src/panel/userId.js +56 -0
  46. package/dist/src/panel/userId.js.map +1 -0
  47. package/dist/src/services/assistantDaemon.d.ts +20 -3
  48. package/dist/src/services/assistantDaemon.d.ts.map +1 -1
  49. package/dist/src/services/assistantDaemon.js +46 -9
  50. package/dist/src/services/assistantDaemon.js.map +1 -1
  51. package/dist/src/services/assistantEval.d.ts +141 -0
  52. package/dist/src/services/assistantEval.d.ts.map +1 -0
  53. package/dist/src/services/assistantEval.js +497 -0
  54. package/dist/src/services/assistantEval.js.map +1 -0
  55. package/dist/src/services/assistantMemory.d.ts +106 -4
  56. package/dist/src/services/assistantMemory.d.ts.map +1 -1
  57. package/dist/src/services/assistantMemory.js +396 -61
  58. package/dist/src/services/assistantMemory.js.map +1 -1
  59. package/dist/src/services/assistantPrivacy.d.ts +15 -2
  60. package/dist/src/services/assistantPrivacy.d.ts.map +1 -1
  61. package/dist/src/services/assistantPrivacy.js +18 -4
  62. package/dist/src/services/assistantPrivacy.js.map +1 -1
  63. package/dist/src/services/assistantRouter.d.ts +41 -0
  64. package/dist/src/services/assistantRouter.d.ts.map +1 -0
  65. package/dist/src/services/assistantRouter.js +151 -0
  66. package/dist/src/services/assistantRouter.js.map +1 -0
  67. package/dist/src/services/assistantService.d.ts +209 -3
  68. package/dist/src/services/assistantService.d.ts.map +1 -1
  69. package/dist/src/services/assistantService.js +590 -75
  70. package/dist/src/services/assistantService.js.map +1 -1
  71. package/dist/src/services/assistantTools.d.ts +205 -1
  72. package/dist/src/services/assistantTools.d.ts.map +1 -1
  73. package/dist/src/services/assistantTools.js +1089 -58
  74. package/dist/src/services/assistantTools.js.map +1 -1
  75. package/dist/src/services/assistantTrace.d.ts +47 -0
  76. package/dist/src/services/assistantTrace.d.ts.map +1 -0
  77. package/dist/src/services/assistantTrace.js +150 -0
  78. package/dist/src/services/assistantTrace.js.map +1 -0
  79. package/dist/src/services/chatService.d.ts +13 -0
  80. package/dist/src/services/chatService.d.ts.map +1 -1
  81. package/dist/src/services/chatService.js +23 -0
  82. package/dist/src/services/chatService.js.map +1 -1
  83. package/dist/src/services/configService.d.ts +9 -0
  84. package/dist/src/services/configService.d.ts.map +1 -1
  85. package/dist/src/services/configService.js +15 -8
  86. package/dist/src/services/configService.js.map +1 -1
  87. package/dist/src/services/messageQuery.d.ts +35 -0
  88. package/dist/src/services/messageQuery.d.ts.map +1 -1
  89. package/dist/src/services/messageQuery.js +61 -0
  90. package/dist/src/services/messageQuery.js.map +1 -1
  91. package/dist/src/services/pythonBridge.d.ts +41 -0
  92. package/dist/src/services/pythonBridge.d.ts.map +1 -0
  93. package/dist/src/services/pythonBridge.js +119 -0
  94. package/dist/src/services/pythonBridge.js.map +1 -0
  95. package/dist/src/services/syncService.d.ts +127 -0
  96. package/dist/src/services/syncService.d.ts.map +1 -0
  97. package/dist/src/services/syncService.js +263 -0
  98. package/dist/src/services/syncService.js.map +1 -0
  99. package/dist/src/services/syncState.d.ts +113 -0
  100. package/dist/src/services/syncState.d.ts.map +1 -0
  101. package/dist/src/services/syncState.js +214 -0
  102. package/dist/src/services/syncState.js.map +1 -0
  103. package/dist/src/services/wechatMessageService.d.ts +25 -0
  104. package/dist/src/services/wechatMessageService.d.ts.map +1 -1
  105. package/dist/src/services/wechatMessageService.js +69 -7
  106. package/dist/src/services/wechatMessageService.js.map +1 -1
  107. package/dist/src/utils/atomicWrite.d.ts +4 -0
  108. package/dist/src/utils/atomicWrite.d.ts.map +1 -0
  109. package/dist/src/utils/atomicWrite.js +60 -0
  110. package/dist/src/utils/atomicWrite.js.map +1 -0
  111. package/dist/src/utils/dateRange.d.ts +15 -0
  112. package/dist/src/utils/dateRange.d.ts.map +1 -1
  113. package/dist/src/utils/dateRange.js +47 -0
  114. package/dist/src/utils/dateRange.js.map +1 -1
  115. package/dist/src/utils/text.d.ts +18 -0
  116. package/dist/src/utils/text.d.ts.map +1 -0
  117. package/dist/src/utils/text.js +20 -0
  118. package/dist/src/utils/text.js.map +1 -0
  119. package/docs/AI_INTERFACE.md +2 -0
  120. package/docs/DECISIONS.md +1484 -20
  121. package/docs/EXTENDING.md +187 -0
  122. package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +338 -0
  123. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -0
  124. package/docs/MCP.md +23 -4
  125. package/docs/PROJECT_STATE.md +299 -96
  126. package/docs/SETUP.md +22 -0
  127. package/docs/SYNC_CONTRACT.md +180 -0
  128. package/mcp-server/index.ts +681 -657
  129. package/package.json +84 -83
  130. package/resources/panel/ball-position.cjs +153 -0
  131. package/resources/panel/index.html +46 -0
  132. package/resources/panel/main.cjs +447 -0
  133. package/resources/panel/mascot-focus.png +0 -0
  134. package/resources/panel/mascot-happy.png +0 -0
  135. package/resources/panel/mascot-sorry.png +0 -0
  136. package/resources/panel/mascot-tired.png +0 -0
  137. package/resources/panel/mascot.png +0 -0
  138. package/resources/panel/package.json +7 -0
  139. package/resources/panel/panel.css +407 -0
  140. package/resources/panel/preload.cjs +72 -0
  141. package/resources/panel/quick-menu.cjs +197 -0
  142. package/resources/panel/renderer.js +441 -0
  143. package/resources/panel/tray-menu.cjs +40 -0
  144. package/resources/panel/tray.png +0 -0
  145. package/scripts/_utils.py +403 -54
  146. package/scripts/annual_report.py +4 -2
  147. package/scripts/article_notes.py +423 -0
  148. package/scripts/backfill_articles.py +438 -0
  149. package/scripts/biz_daily.py +590 -105
  150. package/scripts/chat_notes.py +470 -0
  151. package/scripts/classify_daily.py +10 -18
  152. package/scripts/compile_wiki.py +1059 -46
  153. package/scripts/create_reading_notes.py +199 -14
  154. package/scripts/decide.py +253 -0
  155. package/scripts/draft_reply.py +671 -0
  156. package/scripts/export_chat_html.py +212 -46
  157. package/scripts/extract_todos.py +31 -5
  158. package/scripts/fav_notes.py +318 -0
  159. package/scripts/fix_topics.py +2 -1
  160. package/scripts/generate_ai_report.py +214 -9
  161. package/scripts/generate_html.py +18 -3
  162. package/scripts/generate_review.py +2 -2
  163. package/scripts/jev_client.py +294 -0
  164. package/scripts/jev_probe.py +440 -0
  165. package/scripts/nt_common.py +82 -0
  166. package/scripts/nt_decrypt.py +1755 -1352
  167. package/scripts/pipeline.py +27 -2
  168. package/scripts/promote_ideas.py +19 -4
  169. package/scripts/quality_eval.py +707 -0
  170. package/scripts/rag_chat.py +4 -4
  171. package/scripts/read_image.py +187 -0
  172. package/scripts/reply_debt.py +488 -0
  173. package/scripts/route_cards.py +453 -0
  174. package/scripts/semantic_search.py +208 -49
  175. package/scripts/user_notes.py +315 -0
  176. package/scripts/vault_rag.py +13 -7
  177. package/scripts/vault_search.py +21 -4
  178. package/scripts/wiki_lint.py +397 -0
  179. package/src/core/appMsgFormat.ts +117 -0
  180. package/src/core/ntCore.ts +67 -0
  181. package/src/core/sqlcipherCore.ts +5 -61
  182. package/src/core/wcdbCore.ts +6 -2
  183. package/src/panel/client.ts +49 -0
  184. package/src/panel/endpoint.ts +144 -0
  185. package/src/panel/launch.ts +101 -0
  186. package/src/panel/server.ts +424 -0
  187. package/src/panel/userId.ts +72 -0
  188. package/src/services/assistantDaemon.ts +57 -9
  189. package/src/services/assistantEval.ts +611 -0
  190. package/src/services/assistantMemory.ts +413 -57
  191. package/src/services/assistantPrivacy.ts +18 -4
  192. package/src/services/assistantRouter.ts +191 -0
  193. package/src/services/assistantService.ts +671 -74
  194. package/src/services/assistantTools.ts +1118 -57
  195. package/src/services/assistantTrace.ts +175 -0
  196. package/src/services/chatService.ts +30 -0
  197. package/src/services/configService.ts +403 -385
  198. package/src/services/messageQuery.ts +97 -0
  199. package/src/services/pythonBridge.ts +139 -0
  200. package/src/services/syncService.ts +363 -0
  201. package/src/services/syncState.ts +275 -0
  202. package/src/services/wechatMessageService.ts +73 -7
  203. package/src/utils/atomicWrite.ts +56 -0
  204. package/src/utils/dateRange.ts +46 -0
  205. package/src/utils/text.ts +20 -0
@@ -25,6 +25,11 @@ except ImportError:
25
25
  print("Install sqlcipher3: pip install sqlcipher3")
26
26
  sys.exit(1)
27
27
 
28
+ # 与 nt_decrypt 共用同一份实现(原先两边各有一份,契约还不一样)。
29
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
30
+ from nt_common import (discover_message_shards, derive_database_key,
31
+ table_columns, MESSAGE_ANCHOR_COLUMNS)
32
+
28
33
  PAGE_SIZE = 4096
29
34
  MSG_TYPES = {
30
35
  1: 'text', 3: 'image', 34: 'voice', 42: 'card',
@@ -124,11 +129,30 @@ VOICE_STATE = {'map': {}, 'cache': None}
124
129
  # runs a throwaway formatting pass that only records the URLs (0.1s, no
125
130
  # network), fetches them concurrently, then formats for real against a warm
126
131
  # cache. Set to a list during that pass; None otherwise.
132
+ # Why the most recent remote fetch produced nothing. The skip counter alone
133
+ # said "not fetched" without saying why, so "no budget left" and "the URL is
134
+ # dead" were indistinguishable in the export - and both looked like a bare
135
+ # placeholder to the reader.
136
+ MEDIA_STATE = {'remote_reason': None}
137
+
127
138
  PREFETCH = {'sink': None, 'active': False}
128
139
  PREFETCH_WORKERS = 24
129
140
  PREFETCH_MAX_URLS = 800
130
141
 
131
142
 
143
+ # The columns the row-consuming code below reads by position. Order is part
144
+ # of the contract: `row[4]` is `real_sender_id` and nothing else.
145
+ EXPORT_MESSAGE_COLUMNS = ('local_id', 'server_id', 'local_type', 'sort_seq',
146
+ 'real_sender_id', 'create_time', 'status', 'source',
147
+ 'message_content', 'compress_content')
148
+
149
+ # A column this shard lacks is selected as NULL rather than dropped. Dropping
150
+ # it would shift every later field by one and put, say, `create_time` where
151
+ # `real_sender_id` is expected - wrong output with no error anywhere, which is
152
+ # worse than the empty export a hard failure would produce.
153
+ MISSING_COLUMN_WARNING = ' WARNING: shard has no column %s; reading it as NULL'
154
+
155
+
132
156
  def connect(db_path, key_hex, salt_hex):
133
157
  raw_key = f"x'{key_hex}{salt_hex}'"
134
158
  conn = sqlcipher.connect(db_path)
@@ -160,12 +184,21 @@ def fetch_messages(conn, talker, date=''):
160
184
  date_filter = ' WHERE create_time >= ? AND create_time < ?'
161
185
  date_params = [int(day.timestamp()), int((day + datetime.timedelta(days=1)).timestamp())]
162
186
 
163
- c.execute(f'''
164
- SELECT local_id, server_id, local_type, sort_seq, real_sender_id,
165
- create_time, status, source, message_content, compress_content
166
- FROM "{tbl}"{date_filter}
167
- ORDER BY create_time ASC
168
- ''', date_params)
187
+ available = table_columns(c, tbl)
188
+ for col in EXPORT_MESSAGE_COLUMNS:
189
+ if col not in available:
190
+ print(MISSING_COLUMN_WARNING % col)
191
+ if date and 'create_time' not in available:
192
+ # A requested day cannot be expressed here. Exporting everything
193
+ # instead would silently widen a bounded export into an unbounded one.
194
+ raise ValueError('shard has no create_time column, so a date-bounded export cannot be produced')
195
+ projection = ', '.join('"%s"' % col if col in available else 'NULL AS "%s"' % col
196
+ for col in EXPORT_MESSAGE_COLUMNS)
197
+ order = [col for col in MESSAGE_ANCHOR_COLUMNS
198
+ if col in available]
199
+ order_sql = ' ORDER BY ' + ', '.join('"%s" ASC' % col for col in order) if order else ''
200
+
201
+ c.execute(f'SELECT {projection} FROM "{tbl}"{date_filter}{order_sql}', date_params)
169
202
 
170
203
  messages = []
171
204
  batch = 0
@@ -181,36 +214,11 @@ def fetch_messages(conn, talker, date=''):
181
214
  return messages
182
215
 
183
216
 
184
- def discover_message_shards(db_path):
185
- """Return all NT message shards alongside the configured database."""
186
- db = Path(db_path)
187
- candidates = sorted(db.parent.glob('message_*.db'))
188
- return [str(path) for path in candidates if path.name.lower() not in {
189
- 'message_fts.db', 'message_resource.db'
190
- }]
191
-
192
-
193
- def derive_database_key(path, fallback_key, fallback_salt, passphrase=''):
194
- """Derive a shard-specific SQLCipher key from the shared NT passphrase."""
195
- if not passphrase:
196
- return fallback_key, fallback_salt
197
- try:
198
- with open(path, 'rb') as fh:
199
- salt = fh.read(16)
200
- if len(salt) != 16:
201
- return fallback_key, fallback_salt
202
- raw_passphrase = bytes.fromhex(passphrase)
203
- key = hashlib.pbkdf2_hmac('sha512', raw_passphrase, salt, 256000, 32).hex()
204
- return key, salt.hex()
205
- except (OSError, ValueError):
206
- return fallback_key, fallback_salt
207
-
208
-
209
217
  def fetch_messages_from_shards(db_path, key_hex, salt_hex, talker, date='', passphrase=''):
210
218
  """Read and merge messages from every NT message shard."""
219
+ # `discover_message_shards` 保证不返回空表,所以这里不需要再兜底——
220
+ # 原先的 `if not shards: shards = [db_path]` 是给"可能返回空"的那一份实现擦屁股的。
211
221
  shards = discover_message_shards(db_path)
212
- if not shards:
213
- shards = [db_path]
214
222
  messages = []
215
223
  for shard in shards:
216
224
  shard_conn = None
@@ -752,13 +760,13 @@ def shrink_embedded(image, max_side=720, force=False):
752
760
  return image
753
761
 
754
762
 
755
- def get_cached_image(image_map, local_id, create_time, content='', resource_md5s=None, server_id=0):
756
- """Best local media for a message: by content md5, then by identity.
763
+ def resolve_media(image_map, local_id, create_time, content='', resource_md5s=None, server_id=0):
764
+ """Best local media for a message, with the identity that matched.
757
765
 
758
- md5 is the strongest key, but it is not always derivable - a group row can
759
- lack the resource mapping that carries it. The conversation cache also
760
- indexes by `(local_id, create_time)`, which is identity-aware and was
761
- already being stored; reading it recovers media the md5 path alone misses.
766
+ Returns `(base64, mime, media_key)` or None. `media_key` is the image_map
767
+ key that produced the hit, which is what the coverage report records - so
768
+ a report can never claim a match the page itself did not get, because both
769
+ come from this one function.
762
770
 
763
771
  Deliberately *not* falling back to a bare `local_id`: that id restarts in
764
772
  every message shard, so matching on it alone attaches one conversation's
@@ -769,26 +777,40 @@ def get_cached_image(image_map, local_id, create_time, content='', resource_md5s
769
777
  if not image_map:
770
778
  return None
771
779
  for media_md5 in resource_md5s or []:
772
- if image_map.get(f'md5:{media_md5}'):
773
- return shrink_embedded(image_map[f'md5:{media_md5}'])
780
+ key = f'md5:{media_md5}'
781
+ if image_map.get(key):
782
+ return shrink_embedded(image_map[key]) + (key,)
774
783
  for media_md5 in extract_media_md5s(content):
775
- if image_map.get(f'md5:{media_md5}'):
776
- return shrink_embedded(image_map[f'md5:{media_md5}'])
784
+ key = f'md5:{media_md5}'
785
+ if image_map.get(key):
786
+ return shrink_embedded(image_map[key]) + (key,)
777
787
  try:
778
788
  pair_key = f'pair:{int(local_id)}:{int(create_time)}'
779
789
  except (TypeError, ValueError):
780
790
  return None
781
791
  if image_map.get(pair_key):
782
- return shrink_embedded(image_map[pair_key])
792
+ return shrink_embedded(image_map[pair_key]) + (pair_key,)
783
793
  try:
784
794
  unique_key = f'unique:{int(local_id)}'
785
795
  except (TypeError, ValueError):
786
796
  return None
787
797
  if image_map.get(unique_key):
788
- return shrink_embedded(image_map[unique_key])
798
+ return shrink_embedded(image_map[unique_key]) + (unique_key,)
789
799
  return None
790
800
 
791
801
 
802
+ def get_cached_image(image_map, local_id, create_time, content='', resource_md5s=None, server_id=0):
803
+ """`resolve_media` without the matched key, for every pre-existing caller.
804
+
805
+ md5 is the strongest key, but it is not always derivable - a group row can
806
+ lack the resource mapping that carries it. The conversation cache also
807
+ indexes by `(local_id, create_time)`, which is identity-aware and was
808
+ already being stored; reading it recovers media the md5 path alone misses.
809
+ """
810
+ hit = resolve_media(image_map, local_id, create_time, content, resource_md5s, server_id)
811
+ return (hit[0], hit[1]) if hit else None
812
+
813
+
792
814
  def detect_mime(filepath):
793
815
  """Detect MIME type from file header."""
794
816
  try:
@@ -918,12 +940,14 @@ def download_image_as_base64(url, aes_key='', timeout=10, budgeted=True):
918
940
  already spent their own budget for this URL.
919
941
  """
920
942
  if not url or not url.startswith(('http://', 'https://')):
943
+ MEDIA_STATE['remote_reason'] = 'not-http'
921
944
  return None
922
945
 
923
946
  sink = PREFETCH['sink']
924
947
  if sink is not None:
925
948
  # Dry pass: record the request, spend no budget, touch no network.
926
949
  sink.append(('img', url, aes_key))
950
+ MEDIA_STATE['remote_reason'] = 'deferred-to-prefetch'
927
951
  return None
928
952
 
929
953
  cache_dir = COVER_STATE.get('dir') or ''
@@ -935,11 +959,13 @@ def download_image_as_base64(url, aes_key='', timeout=10, budgeted=True):
935
959
  if cached != 'unknown':
936
960
  if budgeted:
937
961
  COVER_STATE['thumb_cached'] += 1
962
+ MEDIA_STATE['remote_reason'] = None
938
963
  return cached
939
964
 
940
965
  if budgeted and not PREFETCH['active']:
941
966
  if COVER_STATE.get('thumb_budget', 0) <= 0:
942
967
  COVER_STATE['thumb_skipped'] += 1
968
+ MEDIA_STATE['remote_reason'] = 'budget-exhausted'
943
969
  return None
944
970
  COVER_STATE['thumb_budget'] -= 1
945
971
  COVER_STATE['thumb_fetched'] += 1
@@ -956,6 +982,7 @@ def download_image_as_base64(url, aes_key='', timeout=10, budgeted=True):
956
982
  # it so the next export does not pay for the same dead URL.
957
983
  if len(data) > MAX_EMBED_SIZE:
958
984
  _cache_media(cache_file, None)
985
+ MEDIA_STATE['remote_reason'] = 'too-large'
959
986
  return None
960
987
  candidates = [data]
961
988
  decrypted = _decrypt_aes_cbc(data, aes_key)
@@ -969,10 +996,12 @@ def download_image_as_base64(url, aes_key='', timeout=10, budgeted=True):
969
996
  data, mime = wechat_image.shrink(data, mime, max_side=480)
970
997
  result = (base64.b64encode(data).decode(), mime)
971
998
  _cache_media(cache_file, result)
999
+ MEDIA_STATE['remote_reason'] = None
972
1000
  return result
973
1001
  except Exception:
974
1002
  continue
975
1003
  _cache_media(cache_file, None)
1004
+ MEDIA_STATE['remote_reason'] = 'download-failed'
976
1005
  return None
977
1006
 
978
1007
 
@@ -1891,9 +1920,117 @@ def format_message(row, talker, wx_dir, image_map=None, sender_map=None, display
1891
1920
  'local_type': local_type,
1892
1921
  'display': display,
1893
1922
  'image_b64': image_b64,
1923
+ # Built here rather than accumulated in module state: format_message
1924
+ # runs twice (the prefetch dry sweep and the real pass) and the dry
1925
+ # pass is discarded, so a counter would double-count.
1926
+ 'media': build_media_record(
1927
+ local_type, image_b64, image_map, local_id, create_time, content,
1928
+ resource_md5s, metadata_content, bool(server_id)),
1929
+ }
1930
+
1931
+
1932
+ MEDIA_KIND_BY_TYPE = {
1933
+ 3: 'image', 34: 'voice', 43: 'video-thumb', 47: 'emoji', 49: 'link-cover',
1934
+ }
1935
+ MEDIA_KINDS = {'image', 'emoji', 'voice', 'video-thumb', 'link-cover'}
1936
+
1937
+
1938
+ def build_media_report(items, scope, talker, per_page):
1939
+ """Coverage summary plus one entry per media item.
1940
+
1941
+ Written beside the HTML rather than into it: the page is for reading, this
1942
+ is for answering "what is missing and why" without opening the export.
1943
+ Paths, keys and message bodies are not included (D-001/D-002/D-025).
1944
+ """
1945
+ counts = {}
1946
+ for item in items:
1947
+ counts[item['status']] = counts.get(item['status'], 0) + 1
1948
+ counts['total'] = len(items)
1949
+ return {
1950
+ 'schema': 'weflow-media-report/v1',
1951
+ 'source': 'weflow-cli',
1952
+ 'generatedAt': datetime.datetime.now().astimezone().isoformat(timespec='seconds'),
1953
+ 'scope': scope,
1954
+ 'counts': counts,
1955
+ 'budget': {
1956
+ 'coversFetched': COVER_STATE.get('fetched', 0),
1957
+ 'coversCached': COVER_STATE.get('cached', 0),
1958
+ 'coversSkipped': COVER_STATE.get('skipped', 0),
1959
+ 'thumbsFetched': COVER_STATE.get('thumb_fetched', 0),
1960
+ 'thumbsCached': COVER_STATE.get('thumb_cached', 0),
1961
+ 'thumbsSkipped': COVER_STATE.get('thumb_skipped', 0),
1962
+ },
1963
+ 'items': [
1964
+ {
1965
+ 'mediaKey': item['mediaKey'],
1966
+ 'kind': item['kind'],
1967
+ 'status': item['status'],
1968
+ 'mime': item['mime'],
1969
+ 'bytes': item['bytes'],
1970
+ 'sha256': item['sha256'],
1971
+ 'reason': item['reason'],
1972
+ 'locator': {
1973
+ 'part': (item['index'] // per_page + 1) if per_page else 1,
1974
+ 'createTime': None,
1975
+ },
1976
+ }
1977
+ for item in items
1978
+ ],
1894
1979
  }
1895
1980
 
1896
1981
 
1982
+ def build_media_record(local_type, image_b64, image_map, local_id, create_time,
1983
+ content, resource_md5s, metadata_content, has_server_id):
1984
+ """What happened to this message's media, or None for a non-media message.
1985
+
1986
+ Derived from the same `resolve_media` the page used, so the report cannot
1987
+ claim a match the page did not get (or vice versa). A media-free message
1988
+ returns None: this is a media manifest, not a message manifest.
1989
+ """
1990
+ if local_type not in MEDIA_KIND_BY_TYPE:
1991
+ return None
1992
+ kind = MEDIA_KIND_BY_TYPE[local_type]
1993
+ record = {
1994
+ 'mediaKey': None, 'kind': kind, 'status': None,
1995
+ 'mime': None, 'bytes': None, 'sha256': None, 'reason': None,
1996
+ }
1997
+
1998
+ if image_b64:
1999
+ hit = resolve_media(image_map, local_id, create_time, metadata_content,
2000
+ resource_md5s)
2001
+ if hit:
2002
+ # Matched a local key: report which one, so "why this image" is
2003
+ # answerable without re-running the export.
2004
+ record['mediaKey'] = hit[2]
2005
+ record['status'] = 'embedded'
2006
+ else:
2007
+ # Bytes came from somewhere other than the conversation index.
2008
+ if MEDIA_STATE['remote_reason'] is None:
2009
+ record['status'] = 'remote-fetched'
2010
+ record['mediaKey'] = 'remote'
2011
+ else:
2012
+ record['status'] = 'embedded'
2013
+ record['mediaKey'] = 'unindexed-local'
2014
+ raw = base64.b64decode(image_b64)
2015
+ record['bytes'] = len(raw)
2016
+ record['sha256'] = hashlib.sha256(raw).hexdigest()
2017
+ return record
2018
+
2019
+ record['status'] = 'missing'
2020
+ if not has_server_id and not extract_media_md5s(metadata_content) and not resource_md5s:
2021
+ # D-014: with no reliable identity there is nothing safe to match on,
2022
+ # and guessing would attach someone else's media.
2023
+ record['reason'] = 'no-reliable-identity'
2024
+ elif kind == 'voice':
2025
+ record['status'] = 'unsupported'
2026
+ record['reason'] = 'voice-not-in-media-index'
2027
+ elif MEDIA_STATE['remote_reason']:
2028
+ record['reason'] = MEDIA_STATE['remote_reason']
2029
+ else:
2030
+ record['reason'] = 'not-in-local-cache'
2031
+ return record
2032
+
2033
+
1897
2034
  def decode_message_content(value):
1898
2035
  """Decode NT message content, which may be Zstandard compressed."""
1899
2036
  if not value:
@@ -2337,6 +2474,7 @@ def main():
2337
2474
 
2338
2475
  print(f"Formatting {len(messages)} messages...")
2339
2476
  formatted = []
2477
+ media_items = []
2340
2478
  img_hit_count = 0
2341
2479
  article_img_count = 0
2342
2480
  progress_step = max(1, len(messages) // 20)
@@ -2348,6 +2486,12 @@ def main():
2348
2486
  img_hit_count += 1
2349
2487
  if result.get('local_type') == 49:
2350
2488
  article_img_count += 1
2489
+ record = result.get('media')
2490
+ if record:
2491
+ # Index kept so the report can point back at the page part.
2492
+ record = dict(record)
2493
+ record['index'] = i
2494
+ media_items.append(record)
2351
2495
  formatted.append(result)
2352
2496
  print(f" Messages with embedded images: {img_hit_count} (including {article_img_count} article thumbnails)")
2353
2497
 
@@ -2367,6 +2511,20 @@ def main():
2367
2511
  # Use display name for filename if provided, otherwise fallback to wxid
2368
2512
  file_prefix = sanitize_filename(display_name) if display_name else args.talker.replace('@', '_').replace('/', '_')
2369
2513
 
2514
+ # Media coverage report. Written beside the HTML parts, under the same
2515
+ # prefix, so it is always found next to what it describes.
2516
+ media_report = None
2517
+ media_report_path = None
2518
+ if media_items:
2519
+ media_report = build_media_report(media_items, display_name, args.talker, per_part)
2520
+ media_report_path = os.path.join(args.out, f'{file_prefix}_media.json')
2521
+ with open(media_report_path, 'w', encoding='utf-8') as handle:
2522
+ json.dump(media_report, handle, ensure_ascii=False, indent=2)
2523
+ summary = media_report['counts']
2524
+ missing = summary.get('missing', 0) + summary.get('unsupported', 0)
2525
+ print(f"Media report: {len(media_items)} item(s), {missing} unavailable "
2526
+ f"-> {os.path.basename(media_report_path)}")
2527
+
2370
2528
  html_files = []
2371
2529
  for i in range(parts):
2372
2530
  start = i * per_part
@@ -2395,12 +2553,20 @@ def main():
2395
2553
  print(f"\nDone! {len(html_files)} HTML files written to {args.out}")
2396
2554
  print(f"Total: {total} messages")
2397
2555
 
2398
- # Print JSON summary for CLI integration
2556
+ # Print JSON summary for CLI integration.
2557
+ # The four original keys are untouched; `media` is purely additive and the
2558
+ # one existing consumer (src/services/exportService.ts) ignores it.
2399
2559
  print(json.dumps({
2400
2560
  "success": True,
2401
2561
  "total": total,
2402
2562
  "parts": len(html_files),
2403
2563
  "files": html_files,
2564
+ "media": ({
2565
+ "schema": "weflow-media-report/v1",
2566
+ "records": len(media_items),
2567
+ "status": media_report['counts'],
2568
+ "report": os.path.basename(media_report_path) if media_report_path else None,
2569
+ } if media_report else None),
2404
2570
  }))
2405
2571
 
2406
2572
 
@@ -10,6 +10,10 @@
10
10
  python scripts/extract_todos.py rm <id>
11
11
  python scripts/extract_todos.py remind
12
12
 
13
+ `list --json` 输出裸数组(老契约,有其他调用方);加 `--meta` 改成
14
+ `{items, extracted, count}`——`extracted=False` 表示**从没跑过 extract**,
15
+ 与"跑过了但没有待办"是两件事,别把后者说成前者。
16
+
13
17
  数据: ~/.weflow-cli/todos.json
14
18
  """
15
19
 
@@ -18,7 +22,7 @@ from datetime import datetime, timezone, timedelta
18
22
  from pathlib import Path
19
23
 
20
24
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
21
- from _utils import load_config, decrypt_lock, call_deepseek
25
+ from _utils import load_config, decrypt_lock, call_deepseek, get_api_key
22
26
 
23
27
  TODOS_FILE = os.path.join(os.path.expanduser('~'), '.weflow-cli', 'todos.json')
24
28
  TZ = timezone(timedelta(hours=8))
@@ -309,6 +313,10 @@ def main():
309
313
  p.add_argument('--status', choices=['pending', 'done'], help='按状态筛选')
310
314
  p.add_argument('--urgency', choices=['高', '中', '低'], help='按紧急度筛选')
311
315
  p.add_argument('--json', action='store_true', help='JSON 输出')
316
+ # `--json` 保持"裸数组"这个老形状(有调用方按数组消费),所以区分"没提取过"的
317
+ # 那条信息走一个新开关,而不是改老形状。见下面的输出分支。
318
+ p.add_argument('--meta', action='store_true',
319
+ help='与 --json 连用:输出 {items, extracted, count},extracted=False 表示从没跑过 extract')
312
320
 
313
321
  # done
314
322
  p = subparsers.add_parser('done', help='标记待办为已完成')
@@ -341,7 +349,8 @@ def main():
341
349
 
342
350
  if args.command == 'extract':
343
351
  config = load_config()
344
- api_key = getattr(args, 'api_key', None) or os.environ.get('DEEPSEEK_API_KEY', '') or config.get('deepseekApiKey', '')
352
+ api_key = (getattr(args, 'api_key', None)
353
+ or os.environ.get('DEEPSEEK_API_KEY', '') or get_api_key(config))
345
354
  if not api_key:
346
355
  print('[ERROR] 缺少 DeepSeek API key')
347
356
  sys.exit(1)
@@ -351,10 +360,23 @@ def main():
351
360
 
352
361
  elif args.command == 'list':
353
362
  result = list_todos(args.status, args.urgency)
354
- if getattr(args, 'json', False):
363
+ if getattr(args, 'meta', False):
364
+ # "没有待办"与"从没提取过"必须分得开:文件不存在时 `list` 也给空数组,
365
+ # 调用方拿到空数组会说"你没有待办"——而真相是这件事从没跑过。
366
+ # MCP 侧(mcp_bridge.py 的 get_todos)早就在分这两件事,助手工具没分。
367
+ print(json.dumps({
368
+ 'items': result,
369
+ 'extracted': os.path.exists(TODOS_FILE),
370
+ 'count': len(result),
371
+ }, ensure_ascii=False, indent=2))
372
+ elif getattr(args, 'json', False):
355
373
  print(json.dumps(result, ensure_ascii=False, indent=2))
356
374
  elif not result:
357
- print('✅ 暂无待办')
375
+ # 文件不存在时 `list` 也给空——直接说"暂无待办",用户会以为自己真的没事要做。
376
+ if not os.path.exists(TODOS_FILE):
377
+ print('还没提取过待办(先运行:weflow-cli todos extract --days 7)')
378
+ else:
379
+ print('✅ 暂无待办')
358
380
  else:
359
381
  status_labels = {'pending': '⬜', 'done': '✅'}
360
382
  urg_labels = {'高': '🔴', '中': '🟡', '低': '🟢'}
@@ -438,7 +460,11 @@ def main():
438
460
  print(f' - {t["task"]}')
439
461
  print()
440
462
  if result['total_pending'] == 0:
441
- print('✅ 暂无待办,干得好!')
463
+ # 同上:空清单与"从没提取过"是两件事,别把后者夸成前者。
464
+ if not os.path.exists(TODOS_FILE):
465
+ print('还没提取过待办(先运行:weflow-cli todos extract --days 7)')
466
+ else:
467
+ print('✅ 暂无待办,干得好!')
442
468
 
443
469
 
444
470
  if __name__ == '__main__':