weflow-cli 1.16.0 → 1.17.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/CHANGELOG.md +42 -0
- package/README.md +16 -4
- package/bin/weflow-cli.ts +348 -6
- package/dist/bin/weflow-cli.js +381 -8
- package/dist/bin/weflow-cli.js.map +1 -1
- package/dist/src/core/ntCore.d.ts +47 -0
- package/dist/src/core/ntCore.d.ts.map +1 -1
- package/dist/src/core/ntCore.js +24 -0
- package/dist/src/core/ntCore.js.map +1 -1
- package/dist/src/services/assistantService.d.ts +8 -1
- package/dist/src/services/assistantService.d.ts.map +1 -1
- package/dist/src/services/assistantService.js +47 -8
- package/dist/src/services/assistantService.js.map +1 -1
- package/dist/src/services/assistantTools.d.ts +8 -0
- package/dist/src/services/assistantTools.d.ts.map +1 -1
- package/dist/src/services/assistantTools.js +67 -11
- package/dist/src/services/assistantTools.js.map +1 -1
- package/dist/src/services/configService.d.ts.map +1 -1
- package/dist/src/services/configService.js +3 -2
- package/dist/src/services/configService.js.map +1 -1
- package/dist/src/services/cursorProbe.d.ts +44 -0
- package/dist/src/services/cursorProbe.d.ts.map +1 -0
- package/dist/src/services/cursorProbe.js +183 -0
- package/dist/src/services/cursorProbe.js.map +1 -0
- package/dist/src/services/qchatImport.d.ts +89 -0
- package/dist/src/services/qchatImport.d.ts.map +1 -0
- package/dist/src/services/qchatImport.js +273 -0
- package/dist/src/services/qchatImport.js.map +1 -0
- package/dist/src/services/setupVenv.d.ts +43 -0
- package/dist/src/services/setupVenv.d.ts.map +1 -0
- package/dist/src/services/setupVenv.js +198 -0
- package/dist/src/services/setupVenv.js.map +1 -0
- package/dist/src/utils/python.d.ts +10 -0
- package/dist/src/utils/python.d.ts.map +1 -1
- package/dist/src/utils/python.js +27 -4
- package/dist/src/utils/python.js.map +1 -1
- package/docs/DATA_CONTRACT.md +25 -0
- package/docs/DECISIONS.md +92 -30
- package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +365 -365
- package/docs/HEALTH-CHECK.md +104 -104
- package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -310
- package/docs/NEXT_ACTIONS.md +236 -236
- package/docs/NPM-PUBLISH.md +130 -130
- package/docs/POSITIONING.md +68 -68
- package/docs/PROJECT_STATE.md +6 -3
- package/docs/REPLAY.md +169 -0
- package/docs/SYNC_CONTRACT.md +197 -180
- package/package.json +89 -88
- package/requirements-3x.txt +4 -4
- package/requirements-voice.txt +15 -15
- package/resources/js/graph3d/NOTICE.txt +35 -35
- package/resources/js/graph3d/d3-binarytree.min.js +2 -2
- package/resources/js/graph3d/d3-dispatch.min.js +2 -2
- package/resources/js/graph3d/d3-force-3d.min.js +2 -2
- package/resources/js/graph3d/d3-octree.min.js +2 -2
- package/resources/js/graph3d/d3-quadtree.min.js +2 -2
- package/resources/js/graph3d/d3-timer.min.js +2 -2
- package/resources/js/graph3d/three.min.js +6 -6
- package/resources/js/graph3d/viewer2d.js +550 -448
- package/resources/panel/quick-menu.cjs +197 -197
- package/scripts/_utils.py +1338 -1338
- package/scripts/article_notes.py +423 -423
- package/scripts/chat_notes.py +86 -3
- package/scripts/check_readme_commands.py +427 -426
- package/scripts/contact_schema.py +339 -339
- package/scripts/create_reading_notes.py +11 -11
- package/scripts/daily_task.ps1 +213 -213
- package/scripts/fav_notes.py +321 -321
- package/scripts/graph_2d.py +156 -151
- package/scripts/graph_3d.py +672 -646
- package/scripts/health_check.py +473 -473
- package/scripts/imported_chats.py +68 -0
- package/scripts/install_tasks.ps1 +105 -105
- package/scripts/leads.py +179 -179
- package/scripts/network.py +206 -206
- package/scripts/nt_decrypt.py +135 -1
- package/scripts/on_this_day.py +178 -178
- package/scripts/opinion_timeline.py +138 -138
- package/scripts/promises.py +185 -185
- package/scripts/replay.py +803 -0
- package/scripts/run_hidden.vbs +25 -0
- package/scripts/semantic_search.py +73 -5
- package/scripts/user_notes.py +315 -315
- package/scripts/voice_transcribe.py +139 -139
- package/scripts/watch_issues.py +212 -212
- package/scripts/watch_mail.py +131 -131
- package/src/core/ntCore.ts +59 -0
- package/src/services/assistantService.ts +48 -8
- package/src/services/assistantTools.ts +73 -10
- package/src/services/configService.ts +3 -2
- package/src/services/cursorProbe.ts +222 -0
- package/src/services/qchatImport.ts +327 -0
- package/src/services/setupVenv.ts +238 -0
- package/src/services/syncService.ts +363 -363
- package/src/services/syncState.ts +275 -275
- package/src/services/wechatMedia.ts +166 -166
- package/src/utils/python.ts +33 -4
package/scripts/watch_mail.py
CHANGED
|
@@ -1,131 +1,131 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Queue instructions the user sends to the agent mailbox.
|
|
3
|
-
|
|
4
|
-
A pure script - no model, no tokens. It runs from Windows Task Scheduler and
|
|
5
|
-
its only job is to notice new mail from an allowlisted sender and append it to
|
|
6
|
-
a local queue file. Acting on those instructions happens later, from a normal
|
|
7
|
-
session, which is when a model is running anyway.
|
|
8
|
-
|
|
9
|
-
That split matters: waking a model every few minutes just to discover there is
|
|
10
|
-
no mail costs tokens for nothing. This script does the waiting for free.
|
|
11
|
-
|
|
12
|
-
~/.weflow-mail-queue.jsonl one JSON object per pending instruction
|
|
13
|
-
~/.weflow-mail-state.json message ids already queued
|
|
14
|
-
|
|
15
|
-
Only mail whose From address is exactly the allowlisted one is ever read or
|
|
16
|
-
queued. Mail from anyone else is left untouched - not read, not marked, not
|
|
17
|
-
queued.
|
|
18
|
-
"""
|
|
19
|
-
import json
|
|
20
|
-
import os
|
|
21
|
-
import subprocess
|
|
22
|
-
import sys
|
|
23
|
-
|
|
24
|
-
STATE_PATH = os.path.join(os.path.expanduser('~'), '.weflow-mail-state.json')
|
|
25
|
-
QUEUE_PATH = os.path.join(os.path.expanduser('~'), '.weflow-mail-queue.jsonl')
|
|
26
|
-
|
|
27
|
-
# Exact address, not a display name: a sender name can say anything.
|
|
28
|
-
ALLOWED_SENDERS = {'1473517806@qq.com'}
|
|
29
|
-
|
|
30
|
-
AGENTLY_CANDIDATES = [
|
|
31
|
-
# The npm shim is a .cmd on Windows; subprocess needs the extension spelled
|
|
32
|
-
# out, since a bare `agently-cli` is not an executable image.
|
|
33
|
-
r'C:\Users\Administrator\AppData\Roaming\npm\agently-cli.cmd',
|
|
34
|
-
'agently-cli.cmd',
|
|
35
|
-
'agently-cli',
|
|
36
|
-
]
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def _runnable(candidate):
|
|
40
|
-
if os.path.isabs(candidate):
|
|
41
|
-
return os.path.isfile(candidate)
|
|
42
|
-
from shutil import which
|
|
43
|
-
return which(candidate) is not None
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def agently(args):
|
|
47
|
-
"""Run agently-cli and return its parsed JSON, or None."""
|
|
48
|
-
executable = next((c for c in AGENTLY_CANDIDATES if _runnable(c)), None)
|
|
49
|
-
if not executable:
|
|
50
|
-
print('未找到 agently-cli,跳过。')
|
|
51
|
-
return None
|
|
52
|
-
try:
|
|
53
|
-
result = subprocess.run(
|
|
54
|
-
[executable, *args], capture_output=True, timeout=90, encoding='utf-8')
|
|
55
|
-
except (OSError, subprocess.SubprocessError) as error:
|
|
56
|
-
print(f'agently-cli 调用失败: {type(error).__name__}')
|
|
57
|
-
return None
|
|
58
|
-
# agently-cli pretty-prints its JSON across many lines, so parse the whole
|
|
59
|
-
# payload from the first brace rather than looking for a single-line blob.
|
|
60
|
-
text = result.stdout.strip()
|
|
61
|
-
start = text.find('{')
|
|
62
|
-
if start < 0:
|
|
63
|
-
return None
|
|
64
|
-
try:
|
|
65
|
-
return json.JSONDecoder().raw_decode(text[start:])[0]
|
|
66
|
-
except ValueError:
|
|
67
|
-
return None
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
def load_json(path, fallback):
|
|
71
|
-
try:
|
|
72
|
-
with open(path, encoding='utf-8') as handle:
|
|
73
|
-
return json.load(handle)
|
|
74
|
-
except (OSError, ValueError):
|
|
75
|
-
return fallback
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
def main():
|
|
79
|
-
state = load_json(STATE_PATH, {'seen': []})
|
|
80
|
-
seen = set(state.get('seen', []))
|
|
81
|
-
|
|
82
|
-
listing = agently(['message', '+list', '--limit', '20'])
|
|
83
|
-
if not listing or not listing.get('ok'):
|
|
84
|
-
print('读取邮箱失败(未授权或网络不通),本次跳过。')
|
|
85
|
-
return 0
|
|
86
|
-
|
|
87
|
-
messages = (listing.get('data') or {}).get('data') or []
|
|
88
|
-
queued = 0
|
|
89
|
-
for summary in messages:
|
|
90
|
-
sender = (summary.get('from') or {}).get('email', '')
|
|
91
|
-
if sender not in ALLOWED_SENDERS:
|
|
92
|
-
continue
|
|
93
|
-
message_id = summary.get('message_id', '')
|
|
94
|
-
if not message_id or message_id in seen:
|
|
95
|
-
continue
|
|
96
|
-
|
|
97
|
-
detail = agently(['message', '+read', '--id', message_id])
|
|
98
|
-
body = ''
|
|
99
|
-
if detail and detail.get('ok'):
|
|
100
|
-
body = ((detail.get('data') or {}).get('text')
|
|
101
|
-
or (detail.get('data') or {}).get('body') or '')
|
|
102
|
-
if not body:
|
|
103
|
-
body = summary.get('snippet', '')
|
|
104
|
-
|
|
105
|
-
entry = {
|
|
106
|
-
'message_id': message_id,
|
|
107
|
-
'from': sender,
|
|
108
|
-
'subject': summary.get('subject', ''),
|
|
109
|
-
'created_at': summary.get('created_at', ''),
|
|
110
|
-
'body': body,
|
|
111
|
-
}
|
|
112
|
-
with open(QUEUE_PATH, 'a', encoding='utf-8') as handle:
|
|
113
|
-
handle.write(json.dumps(entry, ensure_ascii=False) + '\n')
|
|
114
|
-
seen.add(message_id)
|
|
115
|
-
queued += 1
|
|
116
|
-
|
|
117
|
-
if queued:
|
|
118
|
-
with open(STATE_PATH, 'w', encoding='utf-8') as handle:
|
|
119
|
-
json.dump({'seen': sorted(seen)}, handle, ensure_ascii=False, indent=2)
|
|
120
|
-
print(f'已排队 {queued} 封来自授权发件人的邮件。')
|
|
121
|
-
else:
|
|
122
|
-
# Still persist first-run state so the backlog is not queued later.
|
|
123
|
-
if not os.path.exists(STATE_PATH):
|
|
124
|
-
with open(STATE_PATH, 'w', encoding='utf-8') as handle:
|
|
125
|
-
json.dump({'seen': sorted(seen)}, handle, ensure_ascii=False, indent=2)
|
|
126
|
-
print('无新指令邮件。')
|
|
127
|
-
return 0
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if __name__ == '__main__':
|
|
131
|
-
sys.exit(main())
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Queue instructions the user sends to the agent mailbox.
|
|
3
|
+
|
|
4
|
+
A pure script - no model, no tokens. It runs from Windows Task Scheduler and
|
|
5
|
+
its only job is to notice new mail from an allowlisted sender and append it to
|
|
6
|
+
a local queue file. Acting on those instructions happens later, from a normal
|
|
7
|
+
session, which is when a model is running anyway.
|
|
8
|
+
|
|
9
|
+
That split matters: waking a model every few minutes just to discover there is
|
|
10
|
+
no mail costs tokens for nothing. This script does the waiting for free.
|
|
11
|
+
|
|
12
|
+
~/.weflow-mail-queue.jsonl one JSON object per pending instruction
|
|
13
|
+
~/.weflow-mail-state.json message ids already queued
|
|
14
|
+
|
|
15
|
+
Only mail whose From address is exactly the allowlisted one is ever read or
|
|
16
|
+
queued. Mail from anyone else is left untouched - not read, not marked, not
|
|
17
|
+
queued.
|
|
18
|
+
"""
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
|
|
24
|
+
STATE_PATH = os.path.join(os.path.expanduser('~'), '.weflow-mail-state.json')
|
|
25
|
+
QUEUE_PATH = os.path.join(os.path.expanduser('~'), '.weflow-mail-queue.jsonl')
|
|
26
|
+
|
|
27
|
+
# Exact address, not a display name: a sender name can say anything.
|
|
28
|
+
ALLOWED_SENDERS = {'1473517806@qq.com'}
|
|
29
|
+
|
|
30
|
+
AGENTLY_CANDIDATES = [
|
|
31
|
+
# The npm shim is a .cmd on Windows; subprocess needs the extension spelled
|
|
32
|
+
# out, since a bare `agently-cli` is not an executable image.
|
|
33
|
+
r'C:\Users\Administrator\AppData\Roaming\npm\agently-cli.cmd',
|
|
34
|
+
'agently-cli.cmd',
|
|
35
|
+
'agently-cli',
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _runnable(candidate):
|
|
40
|
+
if os.path.isabs(candidate):
|
|
41
|
+
return os.path.isfile(candidate)
|
|
42
|
+
from shutil import which
|
|
43
|
+
return which(candidate) is not None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def agently(args):
|
|
47
|
+
"""Run agently-cli and return its parsed JSON, or None."""
|
|
48
|
+
executable = next((c for c in AGENTLY_CANDIDATES if _runnable(c)), None)
|
|
49
|
+
if not executable:
|
|
50
|
+
print('未找到 agently-cli,跳过。')
|
|
51
|
+
return None
|
|
52
|
+
try:
|
|
53
|
+
result = subprocess.run(
|
|
54
|
+
[executable, *args], capture_output=True, timeout=90, encoding='utf-8')
|
|
55
|
+
except (OSError, subprocess.SubprocessError) as error:
|
|
56
|
+
print(f'agently-cli 调用失败: {type(error).__name__}')
|
|
57
|
+
return None
|
|
58
|
+
# agently-cli pretty-prints its JSON across many lines, so parse the whole
|
|
59
|
+
# payload from the first brace rather than looking for a single-line blob.
|
|
60
|
+
text = result.stdout.strip()
|
|
61
|
+
start = text.find('{')
|
|
62
|
+
if start < 0:
|
|
63
|
+
return None
|
|
64
|
+
try:
|
|
65
|
+
return json.JSONDecoder().raw_decode(text[start:])[0]
|
|
66
|
+
except ValueError:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def load_json(path, fallback):
|
|
71
|
+
try:
|
|
72
|
+
with open(path, encoding='utf-8') as handle:
|
|
73
|
+
return json.load(handle)
|
|
74
|
+
except (OSError, ValueError):
|
|
75
|
+
return fallback
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def main():
|
|
79
|
+
state = load_json(STATE_PATH, {'seen': []})
|
|
80
|
+
seen = set(state.get('seen', []))
|
|
81
|
+
|
|
82
|
+
listing = agently(['message', '+list', '--limit', '20'])
|
|
83
|
+
if not listing or not listing.get('ok'):
|
|
84
|
+
print('读取邮箱失败(未授权或网络不通),本次跳过。')
|
|
85
|
+
return 0
|
|
86
|
+
|
|
87
|
+
messages = (listing.get('data') or {}).get('data') or []
|
|
88
|
+
queued = 0
|
|
89
|
+
for summary in messages:
|
|
90
|
+
sender = (summary.get('from') or {}).get('email', '')
|
|
91
|
+
if sender not in ALLOWED_SENDERS:
|
|
92
|
+
continue
|
|
93
|
+
message_id = summary.get('message_id', '')
|
|
94
|
+
if not message_id or message_id in seen:
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
detail = agently(['message', '+read', '--id', message_id])
|
|
98
|
+
body = ''
|
|
99
|
+
if detail and detail.get('ok'):
|
|
100
|
+
body = ((detail.get('data') or {}).get('text')
|
|
101
|
+
or (detail.get('data') or {}).get('body') or '')
|
|
102
|
+
if not body:
|
|
103
|
+
body = summary.get('snippet', '')
|
|
104
|
+
|
|
105
|
+
entry = {
|
|
106
|
+
'message_id': message_id,
|
|
107
|
+
'from': sender,
|
|
108
|
+
'subject': summary.get('subject', ''),
|
|
109
|
+
'created_at': summary.get('created_at', ''),
|
|
110
|
+
'body': body,
|
|
111
|
+
}
|
|
112
|
+
with open(QUEUE_PATH, 'a', encoding='utf-8') as handle:
|
|
113
|
+
handle.write(json.dumps(entry, ensure_ascii=False) + '\n')
|
|
114
|
+
seen.add(message_id)
|
|
115
|
+
queued += 1
|
|
116
|
+
|
|
117
|
+
if queued:
|
|
118
|
+
with open(STATE_PATH, 'w', encoding='utf-8') as handle:
|
|
119
|
+
json.dump({'seen': sorted(seen)}, handle, ensure_ascii=False, indent=2)
|
|
120
|
+
print(f'已排队 {queued} 封来自授权发件人的邮件。')
|
|
121
|
+
else:
|
|
122
|
+
# Still persist first-run state so the backlog is not queued later.
|
|
123
|
+
if not os.path.exists(STATE_PATH):
|
|
124
|
+
with open(STATE_PATH, 'w', encoding='utf-8') as handle:
|
|
125
|
+
json.dump({'seen': sorted(seen)}, handle, ensure_ascii=False, indent=2)
|
|
126
|
+
print('无新指令邮件。')
|
|
127
|
+
return 0
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
if __name__ == '__main__':
|
|
131
|
+
sys.exit(main())
|
package/src/core/ntCore.ts
CHANGED
|
@@ -75,6 +75,42 @@ export interface NtMessagesResult extends NtResult {
|
|
|
75
75
|
shards?: ShardReport
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/** One shard's outcome in a `cursor-probe` run (nt_decrypt.py cursor-probe). */
|
|
79
|
+
export interface NtCursorProbeShard {
|
|
80
|
+
/** basename only - the child never reports a full path */
|
|
81
|
+
name: string
|
|
82
|
+
opened: boolean
|
|
83
|
+
reason: string | null
|
|
84
|
+
/** number of Msg_* tables; null when the shard could not be read at all */
|
|
85
|
+
tables: number | null
|
|
86
|
+
tablesSampled?: number
|
|
87
|
+
/** per-table missing anchor-column sets, deduplicated; empty = every table has all three */
|
|
88
|
+
anchorColumnsMissing?: string[][]
|
|
89
|
+
rowsSampled?: number
|
|
90
|
+
/** sampled rows whose server_id was 0 or NULL (identity degrades for them) */
|
|
91
|
+
serverIdMissing?: number
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface NtCursorProbeSample {
|
|
95
|
+
tablesPerShard: number
|
|
96
|
+
rowsPerTable: number
|
|
97
|
+
exhaustive: boolean
|
|
98
|
+
tablesTotal: number
|
|
99
|
+
tablesSampled: number
|
|
100
|
+
rowsSampled: number
|
|
101
|
+
serverIdMissing: number
|
|
102
|
+
/** sampled nonzero server ids seen in more than one (shard, table) location */
|
|
103
|
+
duplicateServerIds: number
|
|
104
|
+
duplicateServerIdSamples?: Array<{ serverId: string; locations: string[] }>
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface NtCursorProbeResult extends NtResult {
|
|
108
|
+
shards?: NtCursorProbeShard[]
|
|
109
|
+
/** shards with at least one message table missing an anchor column */
|
|
110
|
+
schemaAnchorMissingTables?: number
|
|
111
|
+
sample?: NtCursorProbeSample
|
|
112
|
+
}
|
|
113
|
+
|
|
78
114
|
export interface NtContactsResult extends NtResult {
|
|
79
115
|
contacts?: Contact[]
|
|
80
116
|
}
|
|
@@ -392,6 +428,29 @@ export class NtCore {
|
|
|
392
428
|
return { success: true, contacts: result.contacts || [] }
|
|
393
429
|
}
|
|
394
430
|
|
|
431
|
+
/**
|
|
432
|
+
* The NT backend's data-side answer to D-027's stable-cursor question:
|
|
433
|
+
* anchor columns per message table (exhaustive) and a bounded sample of
|
|
434
|
+
* `server_id` coverage at both rowid ends of a few tables per shard.
|
|
435
|
+
* Read-only; the sampling bounds are labelled `exhaustive: false` in the
|
|
436
|
+
* result because they are a measurement, not a guarantee.
|
|
437
|
+
*/
|
|
438
|
+
async cursorProbe(opts: { tablesPerShard?: number; rowsPerTable?: number } = {}): Promise<NtCursorProbeResult> {
|
|
439
|
+
const args: string[] = ['cursor-probe']
|
|
440
|
+
if (opts.tablesPerShard != null) args.push('--tables-per-shard', String(opts.tablesPerShard))
|
|
441
|
+
if (opts.rowsPerTable != null) args.push('--rows-per-table', String(opts.rowsPerTable))
|
|
442
|
+
const result = await this.callPython(args)
|
|
443
|
+
if (result.error) {
|
|
444
|
+
return { success: false, error: result.error }
|
|
445
|
+
}
|
|
446
|
+
return {
|
|
447
|
+
success: true,
|
|
448
|
+
shards: result.shards || [],
|
|
449
|
+
schemaAnchorMissingTables: result.schemaAnchorMissingTables,
|
|
450
|
+
sample: result.sample,
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
395
454
|
// ====== SNS (朋友圈) ======
|
|
396
455
|
|
|
397
456
|
/** 查询 SNS 朋友圈时间线 */
|
|
@@ -69,6 +69,20 @@ export interface TurnInput {
|
|
|
69
69
|
message?: WechatInboundMessage
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* 来信里**已落盘**的图片文件(`wechatMediaDownload` 的下载侧产物,D-128)。
|
|
74
|
+
* 只收图片:语音/视频/文件的消费各自是新的出境决策,不搭车。没开下载或下载失败
|
|
75
|
+
* (`filePath` 为空)时这里为空——那种图片消息照旧整条忽略,行为不变。
|
|
76
|
+
*/
|
|
77
|
+
function inboundImageFiles(msg?: WechatInboundMessage): Array<{ filePath: string }> {
|
|
78
|
+
if (!msg?.components?.length) return []
|
|
79
|
+
const out: Array<{ filePath: string }> = []
|
|
80
|
+
for (const c of msg.components) {
|
|
81
|
+
if (c.type === 'image' && c.filePath) out.push({ filePath: c.filePath })
|
|
82
|
+
}
|
|
83
|
+
return out
|
|
84
|
+
}
|
|
85
|
+
|
|
72
86
|
/**
|
|
73
87
|
* 一轮的结果。**"回什么"与"往哪回"分开**:这里只说回什么,往哪回由调用方的 `deliver` 决定。
|
|
74
88
|
*
|
|
@@ -392,6 +406,11 @@ export class AssistantService {
|
|
|
392
406
|
* 消息是串行处理的(queue),所以实例字段在这里是安全的。
|
|
393
407
|
*/
|
|
394
408
|
private turnCalls = new Set<string>()
|
|
409
|
+
/**
|
|
410
|
+
* 本轮**来信自带**的图片文件(D-128)。消息串行处理,实例字段安全(同 `turnCalls`)。
|
|
411
|
+
* `runToolCall` 建 ctx 时带上,`look_at_image` 的 `#信N` 句柄从这里取。
|
|
412
|
+
*/
|
|
413
|
+
private turnInboundImages: Array<{ filePath: string }> = []
|
|
395
414
|
|
|
396
415
|
/** 白名单为空时,把"该把谁加进去"连同**完整**的发送者 ID 打一行。
|
|
397
416
|
*
|
|
@@ -485,6 +504,7 @@ export class AssistantService {
|
|
|
485
504
|
}
|
|
486
505
|
this.turnCalls.add(callKey)
|
|
487
506
|
const ctx: ToolContext = { userId, memory: this.memory }
|
|
507
|
+
if (this.turnInboundImages.length) ctx.inboundImages = this.turnInboundImages
|
|
488
508
|
const raw = await executeTool(name, args, ctx)
|
|
489
509
|
trace.toolCalls += 1
|
|
490
510
|
trace.steps.push({
|
|
@@ -576,22 +596,37 @@ export class AssistantService {
|
|
|
576
596
|
* **外部只许经由 `enqueueTurn` 调用**(它保证串行);直接调会被 `inFlight` 挡下——
|
|
577
597
|
* 见那个字段的注释。
|
|
578
598
|
*/
|
|
579
|
-
async handleMessage(userId: string, text: string, kind: string
|
|
599
|
+
async handleMessage(userId: string, text: string, kind: string,
|
|
600
|
+
inboundImages: Array<{ filePath: string }> = []): Promise<string> {
|
|
580
601
|
if (this.inFlight) {
|
|
581
602
|
throw new Error('handleMessage 重入:它依赖串行调用(记忆窗口、turnCalls 都是实例字段)')
|
|
582
603
|
}
|
|
583
604
|
this.inFlight = true
|
|
584
605
|
try {
|
|
585
|
-
return await this.handleMessageInner(userId, text, kind)
|
|
606
|
+
return await this.handleMessageInner(userId, text, kind, inboundImages)
|
|
586
607
|
} finally {
|
|
587
608
|
this.inFlight = false
|
|
588
609
|
}
|
|
589
610
|
}
|
|
590
611
|
|
|
591
|
-
private async handleMessageInner(userId: string, text: string, kind: string
|
|
592
|
-
|
|
612
|
+
private async handleMessageInner(userId: string, text: string, kind: string,
|
|
613
|
+
inboundImages: Array<{ filePath: string }> = []): Promise<string> {
|
|
614
|
+
// kind==='image' 只有在**带着已落盘图片**时才是 D-128 那条新路;裸的图片消息
|
|
615
|
+
// (runTurn 已经过滤掉的那种)走到这里仍按老规矩拒绝。
|
|
616
|
+
if (kind !== 'text' && !(kind === 'image' && inboundImages.length)) {
|
|
617
|
+
return '目前只支持文字消息哦'
|
|
618
|
+
}
|
|
593
619
|
|
|
594
|
-
|
|
620
|
+
let t = text.trim()
|
|
621
|
+
// 每轮重置(空数组也覆盖):上一轮的图片不能漏到这一轮的句柄空间里。
|
|
622
|
+
this.turnInboundImages = inboundImages
|
|
623
|
+
// 来信图片的句柄指路牌:**能看才摆**(strict 且非本地推理不摆——与 get_messages 的
|
|
624
|
+
// `#N` 同一规则,不摆出一个工具必定会拒绝的东西)。文件本身挂在工具 ctx 上,
|
|
625
|
+
// 这里只告诉模型"有图、看的话用哪个句柄"。
|
|
626
|
+
if (inboundImages.length && (privacyGate.mode() !== 'strict' || privacyGate.isLocalInference())) {
|
|
627
|
+
const handles = inboundImages.map((_f, i) => `#信${i + 1}`).join('、')
|
|
628
|
+
t += `(这张来信带 ${inboundImages.length} 张图片:${handles}。要看就用 look_at_image,不看也可以直接聊。)`
|
|
629
|
+
}
|
|
595
630
|
const startedAt = Date.now()
|
|
596
631
|
this.turnCalls.clear()
|
|
597
632
|
// 一轮的轨迹:判断了什么、调了什么、几轮、为什么停。**内置指令也要记**——
|
|
@@ -786,7 +821,12 @@ export class AssistantService {
|
|
|
786
821
|
*/
|
|
787
822
|
private async runTurn(input: TurnInput, onLog?: (line: string) => void): Promise<TurnOutcome> {
|
|
788
823
|
if (!this.running) return { status: 'not-running' }
|
|
789
|
-
const { sessionId,
|
|
824
|
+
const { sessionId, kind, origin } = input
|
|
825
|
+
// D-128:带**已落盘**图片的来信从"整条忽略"变成一轮(文本占位);没落盘的
|
|
826
|
+
// (没开下载 / 下载失败)照旧忽略——文件不在,句柄无从谈起。
|
|
827
|
+
const inboundImages = inboundImageFiles(input.message)
|
|
828
|
+
const imageAsTurn = kind === 'image' && inboundImages.length > 0
|
|
829
|
+
const text = imageAsTurn ? (input.text.trim() || '(发来一张图片)') : input.text
|
|
790
830
|
|
|
791
831
|
if (origin === 'wechat') {
|
|
792
832
|
const msg = input.message!
|
|
@@ -797,7 +837,7 @@ export class AssistantService {
|
|
|
797
837
|
this.maybeAnnounceWhitelistBootstrap(access, msg, onLog)
|
|
798
838
|
return { status: 'denied', reason: access.reason }
|
|
799
839
|
}
|
|
800
|
-
if (kind !== 'text') {
|
|
840
|
+
if (kind !== 'text' && !imageAsTurn) {
|
|
801
841
|
onLog?.(` → 忽略非文本消息 (${kind})`)
|
|
802
842
|
return { status: 'ignored', reason: 'non-text' }
|
|
803
843
|
}
|
|
@@ -815,7 +855,7 @@ export class AssistantService {
|
|
|
815
855
|
}
|
|
816
856
|
|
|
817
857
|
try {
|
|
818
|
-
const reply = await this.handleMessage(sessionId, text, kind)
|
|
858
|
+
const reply = await this.handleMessage(sessionId, text, kind, inboundImages)
|
|
819
859
|
this.dailyCount++
|
|
820
860
|
// **这里不报"已回复"**:回没回得出去是投递方才知道的事(微信的 sendText 会失败)。
|
|
821
861
|
// 两处都报的话,微信那条路会在日志里出现两遍"已回复",看着像处理了两条消息。
|
|
@@ -306,6 +306,12 @@ export interface ToolContext {
|
|
|
306
306
|
* 附到对话里(见 `assistantService.toApiMessages`)。
|
|
307
307
|
*/
|
|
308
308
|
pendingImages?: AttachedImage[]
|
|
309
|
+
/**
|
|
310
|
+
* 本轮**来信自带**的图片(Bot 通道按 `wechatMediaDownload` 落盘解密过的文件,D-128)。
|
|
311
|
+
* `look_at_image` 的 `#信N` 句柄指向这里:文件已在本机,不走 `read_image.py`。
|
|
312
|
+
* 与 `pendingImages` 的分工:那是"模型这一轮已经看过的",这是"来信里还没看的"。
|
|
313
|
+
*/
|
|
314
|
+
inboundImages?: Array<{ filePath: string }>
|
|
309
315
|
/**
|
|
310
316
|
* 调用方是**外部机器**(MCP 那条路)时置 true。
|
|
311
317
|
*
|
|
@@ -328,6 +334,28 @@ export interface AttachedImage {
|
|
|
328
334
|
/** 一轮对话里最多看几张图。每次调用一张,这是上限——图片按体积算钱,也按体积算隐私。 */
|
|
329
335
|
const MAX_IMAGES_PER_TURN = 2
|
|
330
336
|
|
|
337
|
+
/**
|
|
338
|
+
* 来信图片的大小上限(D-128)。超了就明说"太大",**不缩放**:入站文件是证据形态,
|
|
339
|
+
* 缩了再给模型看,模型说的就不是那张图(导出路径缩放是因为那是给人看的页面)。
|
|
340
|
+
*/
|
|
341
|
+
const MAX_INBOUND_IMAGE_BYTES = 4 * 1024 * 1024
|
|
342
|
+
|
|
343
|
+
/** 从文件头魔数判图片类型,不信任文件名——来信文件的名字来自服务端报文。 */
|
|
344
|
+
function sniffImageMime(buf: Buffer): string | null {
|
|
345
|
+
if (buf.length >= 8 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) {
|
|
346
|
+
return 'image/png'
|
|
347
|
+
}
|
|
348
|
+
if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) {
|
|
349
|
+
return 'image/jpeg'
|
|
350
|
+
}
|
|
351
|
+
if (buf.length >= 12
|
|
352
|
+
&& buf.subarray(0, 4).toString('latin1') === 'RIFF'
|
|
353
|
+
&& buf.subarray(8, 12).toString('latin1') === 'WEBP') {
|
|
354
|
+
return 'image/webp'
|
|
355
|
+
}
|
|
356
|
+
return null
|
|
357
|
+
}
|
|
358
|
+
|
|
331
359
|
function fmtTime(ts: number): string {
|
|
332
360
|
const n = Number(ts)
|
|
333
361
|
const d = n > 1e12 ? new Date(n) : new Date(n * 1000)
|
|
@@ -738,16 +766,17 @@ export const TOOL_DEFS: ToolDef[] = [
|
|
|
738
766
|
type: 'function',
|
|
739
767
|
function: {
|
|
740
768
|
name: 'look_at_image',
|
|
741
|
-
description: '
|
|
742
|
-
+ '
|
|
743
|
-
+ '
|
|
769
|
+
description: '看一张图片(把图片本身交给模型看,不只是"这里有一张图")。'
|
|
770
|
+
+ '看聊天记录里的图:`image` 填 get_messages 输出里 `[图片 #1234]` 的编号,`contact` 给会话。'
|
|
771
|
+
+ '看**本轮来信自带**的图:`image` 填 `#信1`(只有来信带图时才会出现这个句柄),不用 contact。'
|
|
772
|
+
+ '一轮最多看两张;隐私模式为 strict 时不可用(图片不出发本机)。',
|
|
744
773
|
parameters: {
|
|
745
774
|
type: 'object',
|
|
746
775
|
properties: {
|
|
747
|
-
contact: { type: 'string', description: '
|
|
748
|
-
image: { type: 'string', description: '
|
|
776
|
+
contact: { type: 'string', description: '聊天记录那条路:这张图所在会话的联系人显示名(#信N 不需要)' },
|
|
777
|
+
image: { type: 'string', description: '[图片 #N] 的编号 N,或来信图片的 #信N' },
|
|
749
778
|
},
|
|
750
|
-
required: ['
|
|
779
|
+
required: ['image'],
|
|
751
780
|
},
|
|
752
781
|
},
|
|
753
782
|
},
|
|
@@ -1471,20 +1500,54 @@ export async function executeTool(name: string, args: Record<string, any>, ctx:
|
|
|
1471
1500
|
// 句柄在 get_messages 里长这样:`[图片 #1234]`。允许带不带 `#` 都接受——模型从
|
|
1472
1501
|
// 上下文里抄一个编号过来,"抄得像不像"不该决定它能不能看图。
|
|
1473
1502
|
const image = String(args.image ?? '').replace(/^#/, '').trim()
|
|
1474
|
-
if (!
|
|
1475
|
-
|
|
1476
|
-
|
|
1503
|
+
if (!image) return '(缺少 image 参数)'
|
|
1504
|
+
// 来信自带的图(D-128):句柄 `信N`,与聊天记录的 `#N` 是两个空间。静态校验
|
|
1505
|
+
// 先于隐私门——参数错就是参数错,与档位无关。
|
|
1506
|
+
const inbound = /^信(\d+)$/.exec(image)
|
|
1507
|
+
if (!inbound) {
|
|
1508
|
+
if (!contact) return '(缺少 contact 参数:看聊天记录里的图要说明在哪个会话)'
|
|
1509
|
+
if (!/^\d+$/.test(image)) {
|
|
1510
|
+
return `(image 要填 get_messages 里 [图片 #N] 的编号或来信的 #信N,收到的是「${image.slice(0, 20)}」)`
|
|
1511
|
+
}
|
|
1477
1512
|
}
|
|
1478
1513
|
// 第一层门:strict 模式(第三方聊天正文不出境)下图片也不出去。第二层在
|
|
1479
1514
|
// `toApiMessages` 里——"工具肯给"与"这道门肯放"是两件事。
|
|
1480
1515
|
if (privacyGate.mode() === 'strict' && !privacyGate.isLocalInference()) {
|
|
1481
|
-
return '(当前隐私模式是 strict
|
|
1516
|
+
return '(当前隐私模式是 strict:图片不发出本机,用不了这个工具。'
|
|
1482
1517
|
+ '要让我看图,得把 assistantPrivacy 改成 balanced)'
|
|
1483
1518
|
}
|
|
1484
1519
|
const budget = ctx.pendingImages ?? []
|
|
1485
1520
|
if (budget.length >= MAX_IMAGES_PER_TURN) {
|
|
1486
1521
|
return `(这一轮已经看过 ${MAX_IMAGES_PER_TURN} 张图了,先把已经看到的说完,别再取)`
|
|
1487
1522
|
}
|
|
1523
|
+
|
|
1524
|
+
// ---- 来信自带的图:文件已在本机(wechatMediaDownload 落盘解密过),
|
|
1525
|
+
// 不走 read_image.py。
|
|
1526
|
+
if (inbound) {
|
|
1527
|
+
const files = ctx.inboundImages ?? []
|
|
1528
|
+
const n = Number(inbound[1])
|
|
1529
|
+
const ref = files[n - 1]
|
|
1530
|
+
if (!ref) {
|
|
1531
|
+
return `(本轮来信没有 #信${n} 这张图${files.length ? `(只有 #信1 到 #信${files.length})` : ':来信没带可看的图片文件'})`
|
|
1532
|
+
}
|
|
1533
|
+
let buf: Buffer
|
|
1534
|
+
try {
|
|
1535
|
+
buf = readFileSync(ref.filePath)
|
|
1536
|
+
} catch {
|
|
1537
|
+
return `(读不到 #信${n} 的图片文件:落盘文件已不在(可能被清理过))`
|
|
1538
|
+
}
|
|
1539
|
+
if (buf.length > MAX_INBOUND_IMAGE_BYTES) {
|
|
1540
|
+
// 不缩放(D-128):入站文件是证据形态,缩了再给模型看,模型说的就不是那张图。
|
|
1541
|
+
return `(这张来信图片 ${(buf.length / 1048576).toFixed(1)} MB,超过 ${MAX_INBOUND_IMAGE_BYTES / 1048576} MB 上限,没有附上)`
|
|
1542
|
+
}
|
|
1543
|
+
const mime = sniffImageMime(buf)
|
|
1544
|
+
if (!mime) {
|
|
1545
|
+
return `(#信${n} 的文件头不是 png/jpeg/webp,附上也没有意义)`
|
|
1546
|
+
}
|
|
1547
|
+
;(ctx.pendingImages ??= []).push({ b64: buf.toString('base64'), mime })
|
|
1548
|
+
return `(已附上来信图片 #信${n}(${buf.length} 字节)。你能看到它了,直接据此回答。)`
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1488
1551
|
const talker = await resolveTalker(contact)
|
|
1489
1552
|
// 90 秒。建媒体索引是这条路唯一的成本,且随会话大小走:实测单聊 ~1 秒、
|
|
1490
1553
|
// 一个 3 万条消息的大群 18.9 秒(而那个群本机只有缩略图)。取到的图会落在
|
|
@@ -121,7 +121,7 @@ const CONFIG_DIR = join(homedir(), '.weflow-cli')
|
|
|
121
121
|
const CONFIG_FILE = join(CONFIG_DIR, 'config.json')
|
|
122
122
|
|
|
123
123
|
export class ConfigService {
|
|
124
|
-
private config: CliConfig = { dbPath: '', wxid: '', decryptKey: '', decryptKey3x: '', dataVersion: '', dbPath3x: '', ntDbPath: '', ntKey: '', ntSalt: '', contactDbPath: '', contactKey: '', contactSalt: '', wechatOcToken: '', wechatOcAccountId: '', wechatOcBaseUrl: '', wechatOcSyncBuf: '', wechatOcContextTokens: '', wechatMediaDownload: 'false', whitelist: [], blacklist: [], whitelistEntries: [], blacklistEntries: [], vaultRepo: '', aiEngine: 'deepseek', wereadApiKey: '', deepseekApiKey: '', typesafeApiKey: '', dashscopeApiKey: '', judgeProvider: 'native', openrouterApiKey: '', openrouterJudgeModel: '', judgeLocalUrl: '', snsDbPath: '', snsKey: '', snsSalt: '', favDbPath: '', favKey: '', favPassphrase: '', emoticonSeed: '', assistantPrivacy: 'strict', localModel: '', aiBaseUrl: '', aiModel: '', assistantWhitelist: '', assistantGroupWhitelist: '', assistantGroupRequireMention: 'true', assistantFastRoute: 'off', assistantPanelUser: '', quickReplyContacts: '', skillDirs: '', skillDisabled: '', dailySources: '', dailySourceCategories: '', dailyExcludeTopics: '', dailyAiEnabled: 'true' }
|
|
124
|
+
private config: CliConfig = { dbPath: '', wxid: '', decryptKey: '', decryptKey3x: '', dataVersion: '', dbPath3x: '', ntDbPath: '', ntKey: '', ntSalt: '', contactDbPath: '', contactKey: '', contactSalt: '', wechatOcToken: '', wechatOcAccountId: '', wechatOcBaseUrl: '', wechatOcSyncBuf: '', wechatOcContextTokens: '', wechatMediaDownload: 'false', whitelist: [], blacklist: [], whitelistEntries: [], blacklistEntries: [], vaultRepo: '', aiEngine: 'deepseek', wereadApiKey: '', deepseekApiKey: '', typesafeApiKey: '', dashscopeApiKey: '', judgeProvider: 'native', openrouterApiKey: '', openrouterJudgeModel: '', judgeLocalUrl: '', snsDbPath: '', snsKey: '', snsSalt: '', favDbPath: '', favKey: '', favPassphrase: '', emoticonSeed: '', assistantPrivacy: 'strict', localModel: '', aiBaseUrl: '', aiModel: '', assistantWhitelist: '', assistantGroupWhitelist: '', assistantGroupRequireMention: 'true', assistantFastRoute: 'off', assistantPanelUser: '', quickReplyContacts: '', skillDirs: '', skillDisabled: '', dailySources: '', dailySourceCategories: '', dailyExcludeTopics: '', dailyAiEnabled: 'true', dailyMailTo: '' }
|
|
125
125
|
|
|
126
126
|
/** 本进程修改过、待回写的字段 (多进程并发写保护) */
|
|
127
127
|
private dirty = new Set<keyof CliConfig>()
|
|
@@ -199,6 +199,7 @@ export class ConfigService {
|
|
|
199
199
|
dailySourceCategories: data.dailySourceCategories || '',
|
|
200
200
|
dailyExcludeTopics: data.dailyExcludeTopics || '',
|
|
201
201
|
dailyAiEnabled: data.dailyAiEnabled === 'false' ? 'false' : 'true',
|
|
202
|
+
dailyMailTo: data.dailyMailTo || '',
|
|
202
203
|
}
|
|
203
204
|
}
|
|
204
205
|
} catch (e) {
|
|
@@ -383,7 +384,7 @@ export class ConfigService {
|
|
|
383
384
|
}
|
|
384
385
|
|
|
385
386
|
clear(): void {
|
|
386
|
-
this.config = { dbPath: '', wxid: '', decryptKey: '', decryptKey3x: '', dataVersion: '', dbPath3x: '', ntDbPath: '', ntKey: '', ntSalt: '', contactDbPath: '', contactKey: '', contactSalt: '', wechatOcToken: '', wechatOcAccountId: '', wechatOcBaseUrl: '', wechatOcSyncBuf: '', wechatOcContextTokens: '', wechatMediaDownload: 'false', whitelist: [], blacklist: [], whitelistEntries: [], blacklistEntries: [], vaultRepo: '', aiEngine: 'deepseek', wereadApiKey: '', deepseekApiKey: '', typesafeApiKey: '', dashscopeApiKey: '', judgeProvider: 'native', openrouterApiKey: '', openrouterJudgeModel: '', judgeLocalUrl: '', snsDbPath: '', snsKey: '', snsSalt: '', favDbPath: '', favKey: '', favPassphrase: '', emoticonSeed: '', assistantPrivacy: 'strict', localModel: '', aiBaseUrl: '', aiModel: '', assistantWhitelist: '', assistantGroupWhitelist: '', assistantGroupRequireMention: 'true', assistantFastRoute: 'off', assistantPanelUser: '', quickReplyContacts: '', skillDirs: '', skillDisabled: '', dailySources: '', dailySourceCategories: '', dailyExcludeTopics: '', dailyAiEnabled: 'true' }
|
|
387
|
+
this.config = { dbPath: '', wxid: '', decryptKey: '', decryptKey3x: '', dataVersion: '', dbPath3x: '', ntDbPath: '', ntKey: '', ntSalt: '', contactDbPath: '', contactKey: '', contactSalt: '', wechatOcToken: '', wechatOcAccountId: '', wechatOcBaseUrl: '', wechatOcSyncBuf: '', wechatOcContextTokens: '', wechatMediaDownload: 'false', whitelist: [], blacklist: [], whitelistEntries: [], blacklistEntries: [], vaultRepo: '', aiEngine: 'deepseek', wereadApiKey: '', deepseekApiKey: '', typesafeApiKey: '', dashscopeApiKey: '', judgeProvider: 'native', openrouterApiKey: '', openrouterJudgeModel: '', judgeLocalUrl: '', snsDbPath: '', snsKey: '', snsSalt: '', favDbPath: '', favKey: '', favPassphrase: '', emoticonSeed: '', assistantPrivacy: 'strict', localModel: '', aiBaseUrl: '', aiModel: '', assistantWhitelist: '', assistantGroupWhitelist: '', assistantGroupRequireMention: 'true', assistantFastRoute: 'off', assistantPanelUser: '', quickReplyContacts: '', skillDirs: '', skillDisabled: '', dailySources: '', dailySourceCategories: '', dailyExcludeTopics: '', dailyAiEnabled: 'true', dailyMailTo: '' }
|
|
387
388
|
// clear 意图是全量重置: 所有字段标记为脏, 覆盖磁盘上的全部旧值
|
|
388
389
|
this.dirty = new Set(Object.keys(this.config) as (keyof CliConfig)[])
|
|
389
390
|
this.save()
|