@xmanrui/dsh-im 4.21.0 → 4.21.2

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 (30) hide show
  1. package/README.en.md +10 -0
  2. package/README.md +10 -0
  3. package/lib/client.js +46 -9
  4. package/lib/index.js +281 -280
  5. package/package.json +1 -1
  6. package/plugin-src/client/channels/feishu/index.js +22 -7
  7. package/plugin-src/client/credential-binding.js +2 -0
  8. package/plugin-src/client/i18n.js +6 -0
  9. package/plugin-src/host/channels/feishu/rpc.mjs +2 -1
  10. package/plugin-src/host/index.mjs +7 -0
  11. package/plugin-src/host/injected-context.mjs +104 -0
  12. package/scripts/verify-lan-management.mjs +53 -8
  13. package/src/channels/dingtalk/dingtalk-bridge.mjs +104 -27
  14. package/src/channels/feishu/bridge.mjs +35 -2
  15. package/src/channels/qq/qq-bridge.mjs +27 -2
  16. package/src/channels/shared/context-enhancement.mjs +40 -3
  17. package/src/channels/shared/control-command.mjs +8 -1
  18. package/src/channels/shared/harness-client.mjs +7 -0
  19. package/src/channels/shared/i18n-en/dingtalk.mjs +1 -0
  20. package/src/channels/shared/i18n-en/weixin.mjs +2 -0
  21. package/src/channels/shared/im-source-guidance.mjs +65 -0
  22. package/src/channels/shared/injected-context.mjs +362 -0
  23. package/src/channels/shared/semantic/artifact.mjs +3 -3
  24. package/src/channels/shared/semantic/reply-reference.mjs +2 -1
  25. package/src/channels/shared/text-harness-bridge.mjs +23 -2
  26. package/src/channels/shared/workspace-session.mjs +9 -0
  27. package/src/channels/wecom/wecom-bridge.mjs +11 -1
  28. package/src/channels/wecom-app/wecom-app-bridge.mjs +11 -1
  29. package/src/channels/weixin/weixin-api.mjs +52 -13
  30. package/src/channels/weixin/weixin-bridge.mjs +13 -1
@@ -0,0 +1,362 @@
1
+ // Pair one injected context block with the user message that carried it.
2
+ //
3
+ // The prompt RPC carries no message source, so a channel encodes its source
4
+ // block, its optional guidance, and any quoted reply as leading text of the
5
+ // user prompt. `installInjectedContext` splits them back out at
6
+ // `agent/pre-step` -- after the Agent inbox claimed the message but before
7
+ // anything is committed -- and enters plugin-sourced context messages beside
8
+ // the untouched user text. Pairing is decided by message identity, never by
9
+ // inbox position, so concurrent prompts cannot swap their contexts.
10
+ //
11
+ // The source block describes the message, so it follows it; a quoted reply is
12
+ // material the user pointed at, so it precedes it.
13
+ //
14
+ // Nothing here decides what configuration is in force. A channel writes the
15
+ // guidance its own captured settings produced, and publishes that same captured
16
+ // text out of band (see `im-source-guidance.mjs`); parsing the prompt back into
17
+ // settings would let a user message that merely looks like a block become the
18
+ // Session's guidance.
19
+ //
20
+ // Keep this module free of Node built-ins: the Host bundle imports it.
21
+
22
+ import {
23
+ CONTEXT_ENHANCEMENT_FIELDS,
24
+ INJECTED_CONTEXT_SEPARATOR,
25
+ INJECTED_CONTEXT_TAGS,
26
+ } from './context-enhancement.mjs';
27
+
28
+ /** Source plugin name recorded on every split-out context message. */
29
+ export const INJECTED_CONTEXT_PLUGIN = 'dsh-im';
30
+
31
+ /** Bound for a `notice` summary, mirroring the Host's context-summary bound. */
32
+ export const CONTEXT_SUMMARY_MAX_LENGTH = 120;
33
+
34
+ /** Fallback row label for a quoted reply when the Host passes none. */
35
+ export const DEFAULT_REPLY_LABEL = 'Quoted';
36
+
37
+ /** Fallback row label for a source block whose fields carry no readable value. */
38
+ export const DEFAULT_SOURCE_LABEL = 'Source';
39
+
40
+ /**
41
+ * Every field the source producer can project -- the same canonical list the
42
+ * settings UI validates against, so the two cannot drift. A block is ours only
43
+ * when its body is an object drawn from these keys, whatever it happens to
44
+ * display: `botId`/`chatId`/`threadId` alone still identify our block.
45
+ */
46
+ export const SOURCE_BLOCK_FIELDS = CONTEXT_ENHANCEMENT_FIELDS;
47
+
48
+ let fallbackIdCounter = 0;
49
+
50
+ /** Mint one message identity; prefer a UUID so resumed logs never collide. */
51
+ function defaultNewId() {
52
+ const randomUUID = globalThis.crypto?.randomUUID;
53
+ if (typeof randomUUID === 'function') return randomUUID.call(globalThis.crypto);
54
+ fallbackIdCounter += 1;
55
+ return `dsh-im-context-${Date.now().toString(36)}-${fallbackIdCounter.toString(36)}`;
56
+ }
57
+
58
+ /**
59
+ * One-line account of a source block, for the collapsed transcript row.
60
+ * @param body - the block's JSON body, exactly as the producer wrote it.
61
+ * @returns a bounded human summary, or null when nothing readable is present.
62
+ */
63
+ function sourceSummary(body) {
64
+ const parsed = parseBlockJson(body);
65
+ if (parsed === null) return null;
66
+ const summary = [
67
+ parsed.channel,
68
+ parsed.conversationType,
69
+ parsed.senderName ?? parsed.senderId,
70
+ parsed.conversationTitle,
71
+ ]
72
+ .filter((field) => typeof field === 'string' && field.trim().length > 0)
73
+ .join(' \u00b7 ');
74
+ return bound(summary);
75
+ }
76
+
77
+ /**
78
+ * Read one source row's label.
79
+ *
80
+ * Whether a block is ours and whether it can name itself are different
81
+ * questions: `botId`, `chatId` and `threadId` alone are valid selections that
82
+ * project no readable field, and those blocks must still be recognised.
83
+ *
84
+ * @param labels - localized row labels; `source` names a nameless source row.
85
+ * @returns the row label to use when the block itself offers no readable value.
86
+ */
87
+ function sourceLabel(labels) {
88
+ return typeof labels?.source === 'string' && labels.source
89
+ ? labels.source
90
+ : DEFAULT_SOURCE_LABEL;
91
+ }
92
+
93
+ /**
94
+ * One-line account of a quoted reply: the label plus whoever was quoted.
95
+ * @param body - the block's JSON body, exactly as the producer wrote it.
96
+ * @param labels - localized row labels; `reply` names the quoted-reply row.
97
+ * @returns a bounded human summary, or null when the body is not our JSON.
98
+ */
99
+ function replySummary(body, labels) {
100
+ const parsed = parseBlockJson(body);
101
+ if (parsed === null) return null;
102
+ const label = typeof labels?.reply === 'string' && labels.reply
103
+ ? labels.reply
104
+ : DEFAULT_REPLY_LABEL;
105
+ const author = typeof parsed.authorName === 'string' && parsed.authorName.trim()
106
+ ? parsed.authorName.trim()
107
+ : '';
108
+ return bound(author ? `${label} \u00b7 ${author}` : label);
109
+ }
110
+
111
+ /**
112
+ * Parse one block body as a JSON object.
113
+ * @param body - the text between the block's tags.
114
+ * @returns the parsed plain object, or null.
115
+ */
116
+ function parseBlockJson(body) {
117
+ let parsed;
118
+ try {
119
+ parsed = JSON.parse(body);
120
+ } catch {
121
+ return null;
122
+ }
123
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
124
+ return parsed;
125
+ }
126
+
127
+ /**
128
+ * Decide whether one body is a source block this plugin wrote.
129
+ *
130
+ * Only the shape is judged -- a non-empty object drawn from the eight known
131
+ * fields -- so a block still counts when the selected fields carry no readable
132
+ * value, and a user message that happens to contain some other JSON object does
133
+ * not become one.
134
+ *
135
+ * @param body - the text between the source tags.
136
+ * @returns whether the body is one of our source blocks.
137
+ */
138
+ function isSourceBlock(body) {
139
+ const parsed = parseBlockJson(body);
140
+ if (parsed === null) return false;
141
+ const fields = Object.keys(parsed);
142
+ return fields.length > 0
143
+ && fields.every((field) => SOURCE_BLOCK_FIELDS.includes(field));
144
+ }
145
+
146
+ /** Bound one summary to the row's declared maximum. */
147
+ function bound(summary) {
148
+ if (!summary) return null;
149
+ return summary.length > CONTEXT_SUMMARY_MAX_LENGTH
150
+ ? summary.slice(0, CONTEXT_SUMMARY_MAX_LENGTH)
151
+ : summary;
152
+ }
153
+
154
+ /**
155
+ * Read one tag-delimited block at `cursor`.
156
+ * @param text - the text part being scanned.
157
+ * @param cursor - absolute offset the block must start at.
158
+ * @param open - the opening tag.
159
+ * @param close - the closing tag.
160
+ * @returns the body and the offset just past the closing tag, or null.
161
+ */
162
+ function delimited(text, cursor, open, close) {
163
+ if (!text.startsWith(open, cursor)) return null;
164
+ const end = text.indexOf(close, cursor + open.length);
165
+ if (end === -1) return null;
166
+ return { body: text.slice(cursor + open.length, end), end: end + close.length };
167
+ }
168
+
169
+ /**
170
+ * Read one leading injected block, in the order the producers emit them.
171
+ * @param text - the text part being scanned.
172
+ * @param cursor - absolute offset the block must start at.
173
+ * @param labels - localized row labels.
174
+ * @returns the block and its end offset, or null when nothing matches.
175
+ */
176
+ function blockAt(text, cursor, labels) {
177
+ const tags = INJECTED_CONTEXT_TAGS;
178
+ const source = delimited(text, cursor, tags.sourceOpen, tags.sourceClose);
179
+ if (source !== null) {
180
+ // A body that is not our own JSON is user text that happens to use the tag.
181
+ if (!isSourceBlock(source.body)) return null;
182
+ return {
183
+ end: source.end,
184
+ block: {
185
+ position: 'after',
186
+ form: 'notice',
187
+ summary: sourceSummary(source.body) ?? sourceLabel(labels),
188
+ text: text.slice(cursor, source.end),
189
+ },
190
+ };
191
+ }
192
+ const guidance = delimited(text, cursor, tags.guidanceOpen, tags.guidanceClose);
193
+ if (guidance !== null) {
194
+ // The producer always writes its closing tag on a line of its own.
195
+ const before = guidance.end - tags.guidanceClose.length - 1;
196
+ if (before < cursor || text[before] !== '\n') return null;
197
+ return {
198
+ end: guidance.end,
199
+ block: {
200
+ position: 'after',
201
+ form: 'instructions',
202
+ summary: null,
203
+ text: text.slice(cursor, guidance.end),
204
+ // The producer wraps the body in exactly one newline on each side.
205
+ value: text.slice(cursor + tags.guidanceOpen.length + 1, guidance.end - tags.guidanceClose.length - 1),
206
+ },
207
+ };
208
+ }
209
+ const reply = delimited(text, cursor, tags.replyOpen, tags.replyClose);
210
+ if (reply !== null) {
211
+ const summary = replySummary(reply.body, labels);
212
+ if (summary === null) return null;
213
+ return {
214
+ end: reply.end,
215
+ block: {
216
+ position: 'before', form: 'notice', summary, text: text.slice(cursor, reply.end),
217
+ },
218
+ };
219
+ }
220
+ return null;
221
+ }
222
+
223
+ /**
224
+ * Read the injected blocks leading one text part.
225
+ *
226
+ * Each producer writes `<tag>json</tag>` with an escaped body, so a block's
227
+ * tags cannot be forged from its values and the closing tag is unambiguous.
228
+ * Anything that does not parse as that exact shape is left alone, so ordinary
229
+ * user text stays verbatim even when it mentions the tags.
230
+ *
231
+ * @param text - one text part's exact value.
232
+ * @param options.labels - localized row labels; `reply` names the reply row.
233
+ * @returns the parsed blocks and the remaining user text, or null when the text
234
+ * does not begin with a well-formed block.
235
+ */
236
+ export function splitLeadingInjectedContext(text, options = {}) {
237
+ if (typeof text !== 'string' || text.length === 0) return null;
238
+ const blocks = [];
239
+ let cursor = 0;
240
+ for (;;) {
241
+ const found = blockAt(text, cursor, options.labels);
242
+ if (found === null) break;
243
+ blocks.push(found.block);
244
+ cursor = found.end;
245
+ // The producer joins the blocks it emits into one part with a blank line.
246
+ if (text.startsWith(INJECTED_CONTEXT_SEPARATOR, cursor)) {
247
+ cursor += INJECTED_CONTEXT_SEPARATOR.length;
248
+ }
249
+ }
250
+ if (blocks.length === 0) return null;
251
+ return { blocks, rest: text.slice(cursor) };
252
+ }
253
+
254
+ /**
255
+ * Build the plugin-sourced context message for one parsed block.
256
+ * @param block - one parsed block.
257
+ * @param newId - identity factory for the new message.
258
+ * @param plugin - source plugin name recorded on it.
259
+ * @returns one identified user-role context message.
260
+ */
261
+ function contextMessage(block, newId, plugin) {
262
+ const source = block.form === 'notice' && typeof block.summary === 'string'
263
+ ? { kind: 'plugin', plugin, form: 'notice', summary: block.summary }
264
+ : { kind: 'plugin', plugin, form: block.form };
265
+ return {
266
+ id: newId(),
267
+ role: 'user',
268
+ content: [{ type: 'text', text: block.text }],
269
+ source,
270
+ };
271
+ }
272
+
273
+ /**
274
+ * Read the injected blocks carried by one claimed user message.
275
+ *
276
+ * Blocks may occupy several leading text parts: a channel writes its prefix as
277
+ * its own part and the quoted reply as another, so the scan advances part by
278
+ * part until it meets a part that is not a pure block, or the part that also
279
+ * carries the user's own text.
280
+ *
281
+ * @param message - a message claimed from the Agent inbox.
282
+ * @param labels - localized row labels.
283
+ * @returns the blocks to place before and after the user text, the remaining
284
+ * content, or null when the message carries no injected block.
285
+ */
286
+ function claimedInjectedContext(message, labels) {
287
+ if (message === null || typeof message !== 'object') return null;
288
+ const source = message.source;
289
+ // Only a prompt that travelled through the prompt RPC carries the blocks;
290
+ // the same gate keeps plugin-authored messages out of the rewrite.
291
+ if (source === null || typeof source !== 'object' || source.kind !== 'user'
292
+ || typeof source.rpcId !== 'string' || source.rpcId.length === 0) return null;
293
+ const content = message.content;
294
+ if (!Array.isArray(content) || content.length === 0) return null;
295
+ const before = [];
296
+ const after = [];
297
+ let index = 0;
298
+ let headText;
299
+ while (index < content.length) {
300
+ const part = content[index];
301
+ if (part === null || typeof part !== 'object'
302
+ || part.type !== 'text' || typeof part.text !== 'string') break;
303
+ const split = splitLeadingInjectedContext(part.text, { labels });
304
+ if (split === null) break;
305
+ for (const block of split.blocks) {
306
+ (block.position === 'before' ? before : after).push(block);
307
+ }
308
+ index += 1;
309
+ if (split.rest.length > 0) {
310
+ headText = split.rest;
311
+ break;
312
+ }
313
+ }
314
+ if (before.length === 0 && after.length === 0) return null;
315
+ const remaining = content.slice(index);
316
+ if (headText !== undefined) remaining.unshift({ type: 'text', text: headText });
317
+ // A user message with no content left is never committed; leaving the
318
+ // original message alone keeps the prompt valid instead of dropping it.
319
+ if (remaining.length === 0) return null;
320
+ return { before, after, content: remaining };
321
+ }
322
+
323
+ /**
324
+ * Rewrite the messages entering one step so every injected block becomes its
325
+ * own plugin-sourced context message beside the user text it belongs to.
326
+ *
327
+ * Every other message is returned untouched and in place, so a plugin that
328
+ * matches claimed messages by identity still sees the ones it owns.
329
+ *
330
+ * @param messages - the messages the Agent is about to commit for this step.
331
+ * @param options.newId - identity factory for the added context messages.
332
+ * @param options.plugin - source plugin name recorded on them.
333
+ * @param options.labels - localized row labels; `reply` names the reply row.
334
+ * @param options.ownedGuidance - guidance the Host already materializes for the
335
+ * session; a block carrying exactly this body is not emitted again.
336
+ * @returns a new array when at least one message was split, otherwise null.
337
+ */
338
+ export function rewriteInjectedContextMessages(messages, options = {}) {
339
+ if (!Array.isArray(messages) || messages.length === 0) return null;
340
+ const newId = typeof options.newId === 'function' ? options.newId : defaultNewId;
341
+ const plugin = typeof options.plugin === 'string' && options.plugin
342
+ ? options.plugin
343
+ : INJECTED_CONTEXT_PLUGIN;
344
+ let changed = false;
345
+ const rewritten = [];
346
+ for (const message of messages) {
347
+ const claimed = claimedInjectedContext(message, options.labels);
348
+ if (claimed === null) {
349
+ rewritten.push(message);
350
+ continue;
351
+ }
352
+ const after = typeof options.ownedGuidance === 'string'
353
+ ? claimed.after.filter((block) => !(block.form === 'instructions'
354
+ && block.value === options.ownedGuidance))
355
+ : claimed.after;
356
+ changed = true;
357
+ for (const block of claimed.before) rewritten.push(contextMessage(block, newId, plugin));
358
+ rewritten.push({ ...message, content: claimed.content });
359
+ for (const block of after) rewritten.push(contextMessage(block, newId, plugin));
360
+ }
361
+ return changed ? rewritten : null;
362
+ }
@@ -578,7 +578,7 @@ export function createOutboundArtifactTool({ registry = outboundArtifactRegistry
578
578
  };
579
579
  const definition = Object.freeze({
580
580
  name: OUTBOUND_ARTIFACT_TOOL,
581
- description: 'Send a readable file or generated image to the user through the current conversation. Existing and newly created files are both valid.',
581
+ description: 'Register a readable file or generated image for delivery through the current conversation after this turn. Existing and newly created files are both valid. Success means queued, not sent; do not claim the user has received the file.',
582
582
  parameters: {
583
583
  type: 'object',
584
584
  additionalProperties: false,
@@ -603,7 +603,7 @@ export function createOutboundArtifactTool({ registry = outboundArtifactRegistry
603
603
  },
604
604
  render: (_args, value) => [{
605
605
  type: 'text',
606
- text: `Registered ${value.fileName} (${value.size} bytes) for IM delivery.`,
606
+ text: `Registered ${value.fileName} (${value.size} bytes) for IM delivery after this turn. The file has not been sent yet; describe it as prepared or queued, not sent or received.`,
607
607
  }],
608
608
  },
609
609
  async execute(args, exec) {
@@ -656,7 +656,7 @@ export function installOutboundArtifactTool(ctx, { registry = outboundArtifactRe
656
656
  ctx.systemPrompt.section({
657
657
  name: 'dsh-im:return-file',
658
658
  order: 115,
659
- text: `When the user asks to receive a file or generated image, call ${OUTBOUND_ARTIFACT_TOOL} with its path. Existing files can be sent directly; do not recreate or rename a file solely for delivery.`,
659
+ text: `When the user asks to receive a file or generated image, call ${OUTBOUND_ARTIFACT_TOOL} with its path. Existing files can be sent directly; do not recreate or rename a file solely for delivery. This tool only registers the file; the channel uploads and sends it after your turn finishes. In your reply say the file is prepared or queued, never that it has already been sent or received. The channel reports delivery failures separately.`,
660
660
  });
661
661
  return true;
662
662
  }
@@ -1,3 +1,4 @@
1
+ import { INJECTED_CONTEXT_TAGS } from '../context-enhancement.mjs';
1
2
  import { promptContentForMessage } from '../image-prompt.mjs';
2
3
 
3
4
  const REPLY_CONTENT_MAX_CODE_POINTS = 8_000;
@@ -130,7 +131,7 @@ function replyBlock(reference) {
130
131
  '>': '\\u003e',
131
132
  '&': '\\u0026',
132
133
  })[character]);
133
- return `<dsh_im_reply_to>${json}</dsh_im_reply_to>`;
134
+ return `${INJECTED_CONTEXT_TAGS.replyOpen}${json}${INJECTED_CONTEXT_TAGS.replyClose}`;
134
135
  }
135
136
 
136
137
  export function hasReplyReference(message) {
@@ -7,7 +7,11 @@ import {
7
7
  COMMAND_PERMISSION_DENIED_MESSAGE,
8
8
  evaluateInboundAccess,
9
9
  } from './inbound-access.mjs';
10
- import { captureContextEnhancement, enhanceContextContent } from './context-enhancement.mjs';
10
+ import {
11
+ captureContextEnhancement,
12
+ captureContextEnhancementSource,
13
+ enhanceContextContent,
14
+ } from './context-enhancement.mjs';
11
15
  import { runWorkspaceCommand } from './workspace-command.mjs';
12
16
  import { runCompactCommand } from './compact-command.mjs';
13
17
  import { isHistoryCommand, runHistoryCommand } from './history-command.mjs';
@@ -346,6 +350,7 @@ export class TextHarnessBridge {
346
350
  messageId,
347
351
  key,
348
352
  commandRunner,
353
+ senderId,
349
354
  ).finally(() => {
350
355
  this.#acceptedMessageIds.delete(messageId);
351
356
  this.#commandTasks.delete(task);
@@ -487,7 +492,7 @@ export class TextHarnessBridge {
487
492
  await this.#deferred.whenIdle();
488
493
  }
489
494
 
490
- async #processFastCommand(message, messageId, key, runner) {
495
+ async #processFastCommand(message, messageId, key, runner, senderId) {
491
496
  if (this.#state.hasSeen(messageId)) return;
492
497
  await this.#state.markSeen(messageId);
493
498
  this.#status.messagesReceived += 1;
@@ -508,6 +513,21 @@ export class TextHarnessBridge {
508
513
  || this.#approvals.hasPending(key),
509
514
  control: { owner: this, key },
510
515
  deferredDelivery: this.#deferred,
516
+ enhancement: captureContextEnhancementSource(
517
+ this.#contextEnhancement,
518
+ message.kind,
519
+ () => {
520
+ const source = message.contextSource?.();
521
+ return {
522
+ channel: this.#descriptor.key,
523
+ senderId,
524
+ senderName: source?.senderName,
525
+ conversationTitle: source?.conversationTitle,
526
+ chatId: source?.chatId ?? message.conversationId,
527
+ threadId: source?.threadId,
528
+ };
529
+ },
530
+ ),
511
531
  },
512
532
  );
513
533
  if (result?.stopped) {
@@ -738,6 +758,7 @@ export class TextHarnessBridge {
738
758
  text,
739
759
  content,
740
760
  titleText: batchSubmission?.title,
761
+ sourceGuidance: snapshot?.config?.guidance,
741
762
  contextEnhanced,
742
763
  createOptions: this.#signal ? { signal: this.#signal } : undefined,
743
764
  existsOptions: this.#signal ? { signal: this.#signal } : undefined,
@@ -82,6 +82,11 @@ async function createSession(harness, options) {
82
82
  * user's own words -- a batch submission composes dsh-im's framing sentence and
83
83
  * message labels into one prompt, and only the collected text may name the
84
84
  * conversation.
85
+ *
86
+ * `sourceGuidance` is the guidance the channel's captured enhancement settings
87
+ * applied, carried to the Host out of band so it can materialize it as session
88
+ * prompt context. It is never re-derived from the prompt, which also carries
89
+ * whatever the user typed.
85
90
  */
86
91
  export async function askInWorkspaceSession({
87
92
  harness,
@@ -90,6 +95,7 @@ export async function askInWorkspaceSession({
90
95
  text,
91
96
  content,
92
97
  titleText,
98
+ sourceGuidance,
93
99
  contextEnhanced = false,
94
100
  createOptions,
95
101
  existsOptions,
@@ -151,6 +157,9 @@ export async function askInWorkspaceSession({
151
157
  const artifactOptions = typeof askOptions === 'number'
152
158
  ? { timeoutMs: askOptions }
153
159
  : { ...askOptions };
160
+ // The guidance the channel's captured settings applied, carried out of
161
+ // band so the Host never has to read configuration out of the prompt.
162
+ artifactOptions.sourceGuidance = sourceGuidance;
154
163
  artifactOptions.onArtifact = async (artifact) => {
155
164
  artifacts.push(artifact);
156
165
  await originalOnArtifact?.(artifact);
@@ -33,7 +33,11 @@ import {
33
33
  } from '../shared/preset-command.mjs';
34
34
  import { runWorkspaceCommand, workspacePathSnapshot } from '../shared/workspace-command.mjs';
35
35
  import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
36
- import { captureContextEnhancement, enhanceContextContent } from '../shared/context-enhancement.mjs';
36
+ import {
37
+ captureContextEnhancement,
38
+ captureContextEnhancementSource,
39
+ enhanceContextContent,
40
+ } from '../shared/context-enhancement.mjs';
37
41
  import {
38
42
  hasInboundImages,
39
43
  ImagePromptError,
@@ -1166,6 +1170,11 @@ export class WecomHarnessBridge {
1166
1170
  || this.#approvals.hasPending(key),
1167
1171
  control: { owner: this, key },
1168
1172
  deferredDelivery: this.#deferred,
1173
+ enhancement: captureContextEnhancementSource(
1174
+ this.#contextEnhancement,
1175
+ bodyOf(frame).chattype === 'single' ? 'direct' : 'group',
1176
+ () => ({ channel: 'wecom', senderId: bodyOf(frame).from?.userid, chatId }),
1177
+ ),
1169
1178
  });
1170
1179
  if (result?.stopped) {
1171
1180
  await Promise.allSettled([
@@ -1370,6 +1379,7 @@ export class WecomHarnessBridge {
1370
1379
  text,
1371
1380
  content,
1372
1381
  titleText: batchSubmission?.title,
1382
+ sourceGuidance: snapshot?.config?.guidance,
1373
1383
  contextEnhanced,
1374
1384
  createOptions: { signal: this.#signal },
1375
1385
  existsOptions: { signal: this.#signal },
@@ -26,7 +26,11 @@ import {
26
26
  } from '../shared/preset-command.mjs';
27
27
  import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
28
28
  import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
29
- import { captureContextEnhancement, enhanceContextContent } from '../shared/context-enhancement.mjs';
29
+ import {
30
+ captureContextEnhancement,
31
+ captureContextEnhancementSource,
32
+ enhanceContextContent,
33
+ } from '../shared/context-enhancement.mjs';
30
34
  import {
31
35
  DEFAULT_IMAGE_PROMPT,
32
36
  hasInboundImages,
@@ -498,6 +502,11 @@ export class WecomAppBridge {
498
502
  || this.#approvals.hasPending(key),
499
503
  control: { owner: this, key },
500
504
  deferredDelivery: this.#deferred,
505
+ enhancement: captureContextEnhancementSource(
506
+ this.#contextEnhancement,
507
+ 'direct',
508
+ () => ({ channel: 'wecom-app', senderId: sender, chatId: sender }),
509
+ ),
501
510
  });
502
511
  if (result?.stopped) {
503
512
  await Promise.allSettled([
@@ -606,6 +615,7 @@ export class WecomAppBridge {
606
615
  text,
607
616
  content,
608
617
  titleText: batchSubmission?.title,
618
+ sourceGuidance: snapshot?.config?.guidance,
609
619
  contextEnhanced,
610
620
  createOptions: { signal: this.#signal },
611
621
  existsOptions: { signal: this.#signal },
@@ -21,6 +21,8 @@ const ILINK_CLIENT_VERSION = (2 << 16) | (4 << 8) | 6;
21
21
  const DEFAULT_TIMEOUT_MS = 15_000;
22
22
  const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000;
23
23
  const WEIXIN_CDN_UPLOAD_RETRIES = 3;
24
+ const WEIXIN_CDN_UPLOAD_IDLE_TIMEOUT_MS = 60_000;
25
+ const WEIXIN_CDN_UPLOAD_CHUNK_BYTES = 64 * 1024;
24
26
  const WEIXIN_MESSAGE_ID_TIMESTAMP_SHIFT = 22n;
25
27
  const WEIXIN_MESSAGE_ID_MIN_TIMESTAMP_MS = Date.UTC(2020, 0, 1);
26
28
  const WEIXIN_MESSAGE_ID_MAX_FUTURE_MS = 24 * 60 * 60 * 1_000;
@@ -79,6 +81,9 @@ function weixinArtifactError(cause, { fallback = 'artifact-provider-rejected' }
79
81
  || /(?:rate.?limit|too.?many)/i.test(providerText)) {
80
82
  code = 'artifact-rate-limited';
81
83
  message = 'Weixin rate-limited file delivery.';
84
+ } else if (cause?.code === 'upload-timeout') {
85
+ code = 'artifact-upload-timeout';
86
+ message = 'Weixin file upload stalled; the file message was not sent.';
82
87
  } else if (fallback === 'artifact-provider-rejected') {
83
88
  message = 'Weixin rejected the file message.';
84
89
  }
@@ -339,25 +344,54 @@ function weixinCdnUploadUrl(response, fileKey) {
339
344
  return trustedWeixinCdnUploadUrl(url);
340
345
  }
341
346
 
342
- function encryptWeixinUpload(bytes, key) {
347
+ async function* encryptWeixinUpload(bytes, key, { signal, onProgress }) {
343
348
  const cipher = createCipheriv('aes-128-ecb', key, null);
344
- return Buffer.concat([cipher.update(bytes), cipher.final()]);
349
+ // Let fetch backpressure drive encryption, without keeping whole-file
350
+ // ciphertext copies alongside a potentially large artifact buffer.
351
+ for (let offset = 0; offset < bytes.byteLength; offset += WEIXIN_CDN_UPLOAD_CHUNK_BYTES) {
352
+ signal.throwIfAborted();
353
+ const chunk = cipher.update(bytes.subarray(offset, offset + WEIXIN_CDN_UPLOAD_CHUNK_BYTES));
354
+ onProgress();
355
+ if (chunk.byteLength) yield chunk;
356
+ }
357
+ signal.throwIfAborted();
358
+ onProgress();
359
+ yield cipher.final();
345
360
  }
346
361
 
347
- async function uploadWeixinCdn(fetchImpl, url, ciphertext, { signal } = {}) {
362
+ async function uploadWeixinCdn(fetchImpl, url, bytes, key, { signal } = {}) {
348
363
  let lastError;
349
364
  for (let attempt = 1; attempt <= WEIXIN_CDN_UPLOAD_RETRIES; attempt += 1) {
350
365
  signal?.throwIfAborted();
366
+ const idleController = new AbortController();
367
+ const uploadSignal = signal
368
+ ? AbortSignal.any([signal, idleController.signal])
369
+ : idleController.signal;
370
+ let timer;
371
+ let active = true;
372
+ const onProgress = () => {
373
+ if (!active) return;
374
+ clearTimeout(timer);
375
+ timer = setTimeout(() => idleController.abort(new WeixinApiError(
376
+ 'upload-timeout', '微信文件上传长时间没有进展,已超时。',
377
+ )), WEIXIN_CDN_UPLOAD_IDLE_TIMEOUT_MS);
378
+ };
379
+ const body = encryptWeixinUpload(bytes, key, { signal: uploadSignal, onProgress });
380
+ let response;
381
+ onProgress();
351
382
  try {
352
- const response = await fetchImpl(url, {
383
+ response = await fetchImpl(url, {
353
384
  method: 'POST',
354
- headers: { 'content-type': 'application/octet-stream' },
355
- body: ciphertext,
356
- signal: signal
357
- ? AbortSignal.any([signal, AbortSignal.timeout(60_000)])
358
- : AbortSignal.timeout(60_000),
385
+ headers: {
386
+ 'content-type': 'application/octet-stream',
387
+ 'content-length': String(aesEcbPaddedSize(bytes.byteLength)),
388
+ },
389
+ body,
390
+ duplex: 'half',
391
+ signal: uploadSignal,
359
392
  redirect: 'error',
360
393
  });
394
+ uploadSignal.throwIfAborted();
361
395
  if (response.status >= 400 && response.status < 500) {
362
396
  throw new WeixinApiError(
363
397
  'upload-rejected',
@@ -373,16 +407,21 @@ async function uploadWeixinCdn(fetchImpl, url, ciphertext, { signal } = {}) {
373
407
  );
374
408
  }
375
409
  const downloadParam = nonEmptyString(response.headers.get('x-encrypted-param'));
376
- await response.body?.cancel?.().catch(() => undefined);
377
410
  if (!downloadParam) {
378
411
  throw new WeixinApiError('invalid-upload-response', '微信文件上传响应缺少下载参数。');
379
412
  }
380
413
  return downloadParam;
381
414
  } catch (error) {
382
415
  if (signal?.aborted) throw abortError(signal);
416
+ if (idleController.signal.aborted) error = idleController.signal.reason;
383
417
  if (error instanceof WeixinApiError
384
418
  && (error.code === 'upload-rejected' || error.status < 500)) throw error;
385
419
  lastError = error;
420
+ } finally {
421
+ active = false;
422
+ clearTimeout(timer);
423
+ await body.return();
424
+ await response?.body?.cancel?.().catch(() => undefined);
386
425
  }
387
426
  }
388
427
  if (lastError instanceof WeixinApiError) throw lastError;
@@ -524,10 +563,10 @@ export function createWeixinApi({ fetchImpl = fetch } = {}) {
524
563
  ));
525
564
  }
526
565
  const uploadUrl = weixinCdnUploadUrl(upload, fileKey);
527
- const ciphertext = encryptWeixinUpload(file.bytes, aesKey);
566
+ const ciphertextSize = aesEcbPaddedSize(file.bytes.byteLength);
528
567
  let downloadParam;
529
568
  try {
530
- downloadParam = await uploadWeixinCdn(fetchImpl, uploadUrl, ciphertext, { signal });
569
+ downloadParam = await uploadWeixinCdn(fetchImpl, uploadUrl, file.bytes, aesKey, { signal });
531
570
  } catch (error) {
532
571
  if (signal?.aborted) throw abortError(signal);
533
572
  const status = Number(error?.status);
@@ -564,7 +603,7 @@ export function createWeixinApi({ fetchImpl = fetch } = {}) {
564
603
  client_id: clientId,
565
604
  message_type: 2,
566
605
  message_state: 2,
567
- item_list: [createItem({ file, media, ciphertextSize: ciphertext.byteLength })],
606
+ item_list: [createItem({ file, media, ciphertextSize })],
568
607
  ...(nonEmptyString(contextToken) ? { context_token: contextToken.trim() } : {}),
569
608
  ...(nonEmptyString(runId) ? { run_id: runId.trim() } : {}),
570
609
  },