weflow-cli 1.15.1 → 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.
Files changed (100) hide show
  1. package/CHANGELOG.md +59 -1
  2. package/OPERATIONS.md +16 -1
  3. package/README.en.md +21 -2
  4. package/README.md +34 -6
  5. package/bin/weflow-cli.ts +389 -6
  6. package/dist/bin/weflow-cli.js +422 -8
  7. package/dist/bin/weflow-cli.js.map +1 -1
  8. package/dist/src/core/ntCore.d.ts +47 -0
  9. package/dist/src/core/ntCore.d.ts.map +1 -1
  10. package/dist/src/core/ntCore.js +24 -0
  11. package/dist/src/core/ntCore.js.map +1 -1
  12. package/dist/src/services/assistantService.d.ts +8 -1
  13. package/dist/src/services/assistantService.d.ts.map +1 -1
  14. package/dist/src/services/assistantService.js +47 -8
  15. package/dist/src/services/assistantService.js.map +1 -1
  16. package/dist/src/services/assistantTools.d.ts +8 -0
  17. package/dist/src/services/assistantTools.d.ts.map +1 -1
  18. package/dist/src/services/assistantTools.js +67 -11
  19. package/dist/src/services/assistantTools.js.map +1 -1
  20. package/dist/src/services/configService.d.ts.map +1 -1
  21. package/dist/src/services/configService.js +3 -2
  22. package/dist/src/services/configService.js.map +1 -1
  23. package/dist/src/services/cursorProbe.d.ts +44 -0
  24. package/dist/src/services/cursorProbe.d.ts.map +1 -0
  25. package/dist/src/services/cursorProbe.js +183 -0
  26. package/dist/src/services/cursorProbe.js.map +1 -0
  27. package/dist/src/services/qchatImport.d.ts +89 -0
  28. package/dist/src/services/qchatImport.d.ts.map +1 -0
  29. package/dist/src/services/qchatImport.js +273 -0
  30. package/dist/src/services/qchatImport.js.map +1 -0
  31. package/dist/src/services/setupVenv.d.ts +43 -0
  32. package/dist/src/services/setupVenv.d.ts.map +1 -0
  33. package/dist/src/services/setupVenv.js +198 -0
  34. package/dist/src/services/setupVenv.js.map +1 -0
  35. package/dist/src/utils/python.d.ts +10 -0
  36. package/dist/src/utils/python.d.ts.map +1 -1
  37. package/dist/src/utils/python.js +27 -4
  38. package/dist/src/utils/python.js.map +1 -1
  39. package/docs/DATA_CONTRACT.md +25 -0
  40. package/docs/DECISIONS.md +92 -30
  41. package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +365 -365
  42. package/docs/HEALTH-CHECK.md +104 -104
  43. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -310
  44. package/docs/NEXT_ACTIONS.md +236 -236
  45. package/docs/NPM-PUBLISH.md +130 -130
  46. package/docs/POSITIONING.md +68 -68
  47. package/docs/PROJECT_STATE.md +6 -3
  48. package/docs/REPLAY.md +169 -0
  49. package/docs/SYNC_CONTRACT.md +197 -180
  50. package/package.json +89 -88
  51. package/requirements-3x.txt +4 -4
  52. package/requirements-voice.txt +15 -15
  53. package/resources/js/graph3d/NOTICE.txt +35 -35
  54. package/resources/js/graph3d/d3-binarytree.min.js +2 -2
  55. package/resources/js/graph3d/d3-dispatch.min.js +2 -2
  56. package/resources/js/graph3d/d3-force-3d.min.js +2 -2
  57. package/resources/js/graph3d/d3-octree.min.js +2 -2
  58. package/resources/js/graph3d/d3-quadtree.min.js +2 -2
  59. package/resources/js/graph3d/d3-timer.min.js +2 -2
  60. package/resources/js/graph3d/three.min.js +6 -6
  61. package/resources/js/graph3d/viewer2d.js +550 -448
  62. package/resources/panel/quick-menu.cjs +197 -197
  63. package/scripts/_utils.py +1338 -1338
  64. package/scripts/article_notes.py +423 -423
  65. package/scripts/bonds.py +11 -0
  66. package/scripts/chat_notes.py +86 -3
  67. package/scripts/check_readme_commands.py +427 -426
  68. package/scripts/contact_schema.py +339 -339
  69. package/scripts/create_reading_notes.py +11 -11
  70. package/scripts/daily_task.ps1 +213 -177
  71. package/scripts/fav_notes.py +321 -321
  72. package/scripts/graph_2d.py +156 -151
  73. package/scripts/graph_3d.py +672 -646
  74. package/scripts/health_check.py +473 -473
  75. package/scripts/imported_chats.py +68 -0
  76. package/scripts/install_tasks.ps1 +105 -91
  77. package/scripts/leads.py +179 -0
  78. package/scripts/network.py +206 -206
  79. package/scripts/nt_decrypt.py +135 -1
  80. package/scripts/on_this_day.py +178 -178
  81. package/scripts/opinion_timeline.py +138 -138
  82. package/scripts/promises.py +185 -185
  83. package/scripts/replay.py +803 -0
  84. package/scripts/run_hidden.vbs +25 -0
  85. package/scripts/semantic_search.py +73 -5
  86. package/scripts/user_notes.py +315 -315
  87. package/scripts/voice_transcribe.py +139 -139
  88. package/scripts/watch_issues.py +212 -212
  89. package/scripts/watch_mail.py +131 -131
  90. package/src/core/ntCore.ts +59 -0
  91. package/src/services/assistantService.ts +48 -8
  92. package/src/services/assistantTools.ts +73 -10
  93. package/src/services/configService.ts +3 -2
  94. package/src/services/cursorProbe.ts +222 -0
  95. package/src/services/qchatImport.ts +327 -0
  96. package/src/services/setupVenv.ts +238 -0
  97. package/src/services/syncService.ts +363 -363
  98. package/src/services/syncState.ts +275 -275
  99. package/src/services/wechatMedia.ts +166 -166
  100. package/src/utils/python.ts +33 -4
@@ -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())
@@ -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): Promise<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): Promise<string> {
592
- if (kind !== 'text') return '目前只支持文字消息哦'
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
- const t = text.trim()
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, text, kind, origin } = input
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
- + '`image` 填 get_messages 输出里 `[图片 #1234]` 的编号。'
743
- + '一轮最多看两张;隐私模式为 strict 时不可用(聊天图片不出发本机)。',
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: 'get_messages 里 [图片 #N] 的编号 N' },
776
+ contact: { type: 'string', description: '聊天记录那条路:这张图所在会话的联系人显示名(#信N 不需要)' },
777
+ image: { type: 'string', description: '[图片 #N] 的编号 N,或来信图片的 #信N' },
749
778
  },
750
- required: ['contact', 'image'],
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 (!contact || !image) return '(缺少 contact 或 image 参数)'
1475
- if (!/^\d+$/.test(image)) {
1476
- return `(image 要填 get_messages 里 [图片 #N] 的编号,收到的是「${image.slice(0, 20)}」)`
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()