weflow-cli 1.6.4 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +3 -2
- package/CHANGELOG.md +1966 -0
- package/OPERATIONS.md +808 -0
- package/README.en.md +93 -7
- package/README.md +31 -6
- package/bin/weflow-cli.ts +1245 -26
- package/dist/bin/weflow-cli.js +1349 -72
- package/dist/bin/weflow-cli.js.map +1 -1
- package/dist/mcp-server/index.js +39 -13
- package/dist/mcp-server/index.js.map +1 -1
- package/dist/src/core/appMsgFormat.d.ts +37 -0
- package/dist/src/core/appMsgFormat.d.ts.map +1 -0
- package/dist/src/core/appMsgFormat.js +106 -0
- package/dist/src/core/appMsgFormat.js.map +1 -0
- package/dist/src/core/ntCore.d.ts +43 -0
- package/dist/src/core/ntCore.d.ts.map +1 -1
- package/dist/src/core/ntCore.js +31 -0
- package/dist/src/core/ntCore.js.map +1 -1
- package/dist/src/core/sqlcipherCore.d.ts +0 -12
- package/dist/src/core/sqlcipherCore.d.ts.map +1 -1
- package/dist/src/core/sqlcipherCore.js +5 -64
- package/dist/src/core/sqlcipherCore.js.map +1 -1
- package/dist/src/core/wcdbCore.d.ts +1 -1
- package/dist/src/core/wcdbCore.d.ts.map +1 -1
- package/dist/src/core/wcdbCore.js +5 -1
- package/dist/src/core/wcdbCore.js.map +1 -1
- package/dist/src/panel/client.d.ts +25 -0
- package/dist/src/panel/client.d.ts.map +1 -0
- package/dist/src/panel/client.js +34 -0
- package/dist/src/panel/client.js.map +1 -0
- package/dist/src/panel/endpoint.d.ts +45 -0
- package/dist/src/panel/endpoint.d.ts.map +1 -0
- package/dist/src/panel/endpoint.js +132 -0
- package/dist/src/panel/endpoint.js.map +1 -0
- package/dist/src/panel/launch.d.ts +50 -0
- package/dist/src/panel/launch.d.ts.map +1 -0
- package/dist/src/panel/launch.js +74 -0
- package/dist/src/panel/launch.js.map +1 -0
- package/dist/src/panel/server.d.ts +23 -0
- package/dist/src/panel/server.d.ts.map +1 -0
- package/dist/src/panel/server.js +413 -0
- package/dist/src/panel/server.js.map +1 -0
- package/dist/src/panel/userId.d.ts +37 -0
- package/dist/src/panel/userId.d.ts.map +1 -0
- package/dist/src/panel/userId.js +56 -0
- package/dist/src/panel/userId.js.map +1 -0
- package/dist/src/services/assistantDaemon.d.ts +20 -3
- package/dist/src/services/assistantDaemon.d.ts.map +1 -1
- package/dist/src/services/assistantDaemon.js +46 -9
- package/dist/src/services/assistantDaemon.js.map +1 -1
- package/dist/src/services/assistantEval.d.ts +141 -0
- package/dist/src/services/assistantEval.d.ts.map +1 -0
- package/dist/src/services/assistantEval.js +497 -0
- package/dist/src/services/assistantEval.js.map +1 -0
- package/dist/src/services/assistantMemory.d.ts +106 -4
- package/dist/src/services/assistantMemory.d.ts.map +1 -1
- package/dist/src/services/assistantMemory.js +396 -61
- package/dist/src/services/assistantMemory.js.map +1 -1
- package/dist/src/services/assistantPrivacy.d.ts +15 -2
- package/dist/src/services/assistantPrivacy.d.ts.map +1 -1
- package/dist/src/services/assistantPrivacy.js +18 -4
- package/dist/src/services/assistantPrivacy.js.map +1 -1
- package/dist/src/services/assistantRouter.d.ts +41 -0
- package/dist/src/services/assistantRouter.d.ts.map +1 -0
- package/dist/src/services/assistantRouter.js +151 -0
- package/dist/src/services/assistantRouter.js.map +1 -0
- package/dist/src/services/assistantService.d.ts +209 -3
- package/dist/src/services/assistantService.d.ts.map +1 -1
- package/dist/src/services/assistantService.js +590 -75
- package/dist/src/services/assistantService.js.map +1 -1
- package/dist/src/services/assistantTools.d.ts +205 -1
- package/dist/src/services/assistantTools.d.ts.map +1 -1
- package/dist/src/services/assistantTools.js +1089 -58
- package/dist/src/services/assistantTools.js.map +1 -1
- package/dist/src/services/assistantTrace.d.ts +47 -0
- package/dist/src/services/assistantTrace.d.ts.map +1 -0
- package/dist/src/services/assistantTrace.js +150 -0
- package/dist/src/services/assistantTrace.js.map +1 -0
- package/dist/src/services/chatService.d.ts +13 -0
- package/dist/src/services/chatService.d.ts.map +1 -1
- package/dist/src/services/chatService.js +23 -0
- package/dist/src/services/chatService.js.map +1 -1
- package/dist/src/services/configService.d.ts +9 -0
- package/dist/src/services/configService.d.ts.map +1 -1
- package/dist/src/services/configService.js +15 -8
- package/dist/src/services/configService.js.map +1 -1
- package/dist/src/services/messageQuery.d.ts +35 -0
- package/dist/src/services/messageQuery.d.ts.map +1 -1
- package/dist/src/services/messageQuery.js +61 -0
- package/dist/src/services/messageQuery.js.map +1 -1
- package/dist/src/services/pythonBridge.d.ts +41 -0
- package/dist/src/services/pythonBridge.d.ts.map +1 -0
- package/dist/src/services/pythonBridge.js +119 -0
- package/dist/src/services/pythonBridge.js.map +1 -0
- package/dist/src/services/syncService.d.ts +127 -0
- package/dist/src/services/syncService.d.ts.map +1 -0
- package/dist/src/services/syncService.js +263 -0
- package/dist/src/services/syncService.js.map +1 -0
- package/dist/src/services/syncState.d.ts +113 -0
- package/dist/src/services/syncState.d.ts.map +1 -0
- package/dist/src/services/syncState.js +214 -0
- package/dist/src/services/syncState.js.map +1 -0
- package/dist/src/services/wechatMessageService.d.ts +25 -0
- package/dist/src/services/wechatMessageService.d.ts.map +1 -1
- package/dist/src/services/wechatMessageService.js +69 -7
- package/dist/src/services/wechatMessageService.js.map +1 -1
- package/dist/src/utils/atomicWrite.d.ts +4 -0
- package/dist/src/utils/atomicWrite.d.ts.map +1 -0
- package/dist/src/utils/atomicWrite.js +60 -0
- package/dist/src/utils/atomicWrite.js.map +1 -0
- package/dist/src/utils/dateRange.d.ts +15 -0
- package/dist/src/utils/dateRange.d.ts.map +1 -1
- package/dist/src/utils/dateRange.js +47 -0
- package/dist/src/utils/dateRange.js.map +1 -1
- package/dist/src/utils/text.d.ts +18 -0
- package/dist/src/utils/text.d.ts.map +1 -0
- package/dist/src/utils/text.js +20 -0
- package/dist/src/utils/text.js.map +1 -0
- package/docs/AI_INTERFACE.md +2 -0
- package/docs/DECISIONS.md +1484 -20
- package/docs/EXTENDING.md +187 -0
- package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +338 -0
- package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -0
- package/docs/MCP.md +23 -4
- package/docs/PROJECT_STATE.md +299 -96
- package/docs/SETUP.md +22 -0
- package/docs/SYNC_CONTRACT.md +180 -0
- package/mcp-server/index.ts +681 -657
- package/package.json +84 -83
- package/resources/panel/ball-position.cjs +153 -0
- package/resources/panel/index.html +46 -0
- package/resources/panel/main.cjs +447 -0
- package/resources/panel/mascot-focus.png +0 -0
- package/resources/panel/mascot-happy.png +0 -0
- package/resources/panel/mascot-sorry.png +0 -0
- package/resources/panel/mascot-tired.png +0 -0
- package/resources/panel/mascot.png +0 -0
- package/resources/panel/package.json +7 -0
- package/resources/panel/panel.css +407 -0
- package/resources/panel/preload.cjs +72 -0
- package/resources/panel/quick-menu.cjs +197 -0
- package/resources/panel/renderer.js +441 -0
- package/resources/panel/tray-menu.cjs +40 -0
- package/resources/panel/tray.png +0 -0
- package/scripts/_utils.py +403 -54
- package/scripts/annual_report.py +4 -2
- package/scripts/article_notes.py +423 -0
- package/scripts/backfill_articles.py +438 -0
- package/scripts/biz_daily.py +590 -105
- package/scripts/chat_notes.py +470 -0
- package/scripts/classify_daily.py +10 -18
- package/scripts/compile_wiki.py +1059 -46
- package/scripts/create_reading_notes.py +199 -14
- package/scripts/decide.py +253 -0
- package/scripts/draft_reply.py +671 -0
- package/scripts/export_chat_html.py +212 -46
- package/scripts/extract_todos.py +31 -5
- package/scripts/fav_notes.py +318 -0
- package/scripts/fix_topics.py +2 -1
- package/scripts/generate_ai_report.py +214 -9
- package/scripts/generate_html.py +18 -3
- package/scripts/generate_review.py +2 -2
- package/scripts/jev_client.py +294 -0
- package/scripts/jev_probe.py +440 -0
- package/scripts/nt_common.py +82 -0
- package/scripts/nt_decrypt.py +1755 -1352
- package/scripts/pipeline.py +27 -2
- package/scripts/promote_ideas.py +19 -4
- package/scripts/quality_eval.py +707 -0
- package/scripts/rag_chat.py +4 -4
- package/scripts/read_image.py +187 -0
- package/scripts/reply_debt.py +488 -0
- package/scripts/route_cards.py +453 -0
- package/scripts/semantic_search.py +208 -49
- package/scripts/user_notes.py +315 -0
- package/scripts/vault_rag.py +13 -7
- package/scripts/vault_search.py +21 -4
- package/scripts/wiki_lint.py +397 -0
- package/src/core/appMsgFormat.ts +117 -0
- package/src/core/ntCore.ts +67 -0
- package/src/core/sqlcipherCore.ts +5 -61
- package/src/core/wcdbCore.ts +6 -2
- package/src/panel/client.ts +49 -0
- package/src/panel/endpoint.ts +144 -0
- package/src/panel/launch.ts +101 -0
- package/src/panel/server.ts +424 -0
- package/src/panel/userId.ts +72 -0
- package/src/services/assistantDaemon.ts +57 -9
- package/src/services/assistantEval.ts +611 -0
- package/src/services/assistantMemory.ts +413 -57
- package/src/services/assistantPrivacy.ts +18 -4
- package/src/services/assistantRouter.ts +191 -0
- package/src/services/assistantService.ts +671 -74
- package/src/services/assistantTools.ts +1118 -57
- package/src/services/assistantTrace.ts +175 -0
- package/src/services/chatService.ts +30 -0
- package/src/services/configService.ts +403 -385
- package/src/services/messageQuery.ts +97 -0
- package/src/services/pythonBridge.ts +139 -0
- package/src/services/syncService.ts +363 -0
- package/src/services/syncState.ts +275 -0
- package/src/services/wechatMessageService.ts +73 -7
- package/src/utils/atomicWrite.ts +56 -0
- package/src/utils/dateRange.ts +46 -0
- package/src/utils/text.ts +20 -0
|
@@ -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
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
756
|
-
"""Best local media for a message
|
|
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
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
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
|
-
|
|
773
|
-
|
|
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
|
-
|
|
776
|
-
|
|
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
|
|
package/scripts/extract_todos.py
CHANGED
|
@@ -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)
|
|
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, '
|
|
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
|
-
|
|
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
|
-
|
|
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__':
|