@xmanrui/dsh-im 4.21.0 → 4.21.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "4.21.0",
3
+ "version": "4.21.1",
4
4
  "description": "把十一种 IM 渠道和公网 AI Office 接入本机 DeepSeek Harness。 Connect eleven IM channels and a public AI Office to a local DeepSeek Harness.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -17,6 +17,7 @@ import { installDeliveryRpc } from './delivery-rpc.mjs';
17
17
  import { installDeliveryHttp } from './delivery-http.mjs';
18
18
  import { createDeliveryService } from './delivery-service.mjs';
19
19
  import { installInboundTtlRpc } from './inbound-ttl-rpc.mjs';
20
+ import { installInjectedContext } from './injected-context.mjs';
20
21
  import { installSessionSyncCoordinator } from './session-sync-coordinator.mjs';
21
22
  import { installSessionTitlePrefix } from './session-title-prefix.mjs';
22
23
  import { installUpdateRpc } from './update-rpc.mjs';
@@ -41,6 +42,7 @@ export function createImHostPlugin(internals = {}) {
41
42
  const startHostLanguageRpc = internals.installHostLanguageRpc ?? installHostLanguageRpc;
42
43
  const startUpdate = internals.installUpdateRpc ?? installUpdateRpc;
43
44
  const startInboundTtl = internals.installInboundTtlRpc ?? installInboundTtlRpc;
45
+ const startInjectedContext = internals.installInjectedContext ?? installInjectedContext;
44
46
  const startDelivery = internals.installDeliveryRpc ?? installDeliveryRpc;
45
47
  const startDeliveryHttp = internals.installDeliveryHttp ?? installDeliveryHttp;
46
48
  const startSessionSync = internals.installSessionSyncCoordinator
@@ -139,6 +141,11 @@ export function createImHostPlugin(internals = {}) {
139
141
  const logger = typeof ctx?.logger === 'function'
140
142
  ? ctx.logger(name)
141
143
  : (ctx?.logger ?? console);
144
+ try {
145
+ startInjectedContext(ctx, { logger });
146
+ } catch (error) {
147
+ logger.error?.('[dsh-im] failed to activate injected-context pairing; prompts keep the inline prefix', error);
148
+ }
142
149
  if (ctx?.connection?.fetch) {
143
150
  if (hostLanguage) {
144
151
  try {
@@ -0,0 +1,104 @@
1
+ import { getImHostLanguage } from '../../src/channels/shared/i18n.mjs';
2
+ import { rewriteInjectedContextMessages } from '../../src/channels/shared/injected-context.mjs';
3
+ import {
4
+ IM_SOURCE_GUIDANCE_CONTEXT,
5
+ IM_SOURCE_GUIDANCE_ORDER,
6
+ imSourceGuidance,
7
+ } from '../../src/channels/shared/im-source-guidance.mjs';
8
+
9
+ /** Row label for a quoted reply, in the mirrored Host interface language. */
10
+ const REPLY_LABELS = Object.freeze({ zh: '\u5f15\u7528', en: 'Quoted' });
11
+
12
+ /** Row label for a source block that names no readable field of its own. */
13
+ const SOURCE_LABELS = Object.freeze({ zh: '\u6765\u6e90', en: 'Source' });
14
+
15
+ /** Read the current reply-row label; an unknown language falls back to English. */
16
+ function replyLabel() {
17
+ try {
18
+ return getImHostLanguage() === 'en' ? REPLY_LABELS.en : REPLY_LABELS.zh;
19
+ } catch {
20
+ return REPLY_LABELS.en;
21
+ }
22
+ }
23
+
24
+ /** Read the current nameless-source-row label; unknown languages fall back. */
25
+ function sourceLabel() {
26
+ try {
27
+ return getImHostLanguage() === 'en' ? SOURCE_LABELS.en : SOURCE_LABELS.zh;
28
+ } catch {
29
+ return SOURCE_LABELS.en;
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Pair every injected context block with the user message that carried it, and
35
+ * materialize the source guidance as session-level prompt context.
36
+ *
37
+ * The prompt RPC cannot carry a message source, so a channel writes its source
38
+ * block, guidance and quoted reply into the prompt text. `agent/pre-step` is
39
+ * the one point where the claimed messages are already known and nothing is
40
+ * committed yet: splitting the blocks there records each as its own
41
+ * plugin-sourced message next to exactly the user message it belongs to,
42
+ * whatever the inbox interleaved meanwhile. A Host without the Agent loop never
43
+ * dispatches the event, and the prompt then keeps its inline blocks.
44
+ *
45
+ * Guidance is not per message but per conversation, so it is registered as
46
+ * dynamic prompt context instead: the Host appends one durable snapshot per
47
+ * Session and only re-renders it when a channel publishes different guidance.
48
+ * Deployment policy still decides whether such snapshots exist at all, which is
49
+ * why the message-side copy is kept until a channel has published a matching
50
+ * value for that Session.
51
+ *
52
+ * @param ctx - owning Host context.
53
+ * @param options.logger - Host logger for a rewrite that could not be trusted.
54
+ * @param options.registry - guidance registry; tests inject their own.
55
+ * @returns the Cordis disposer, or null when the context cannot listen.
56
+ */
57
+ export function installInjectedContext(ctx, { logger, registry = imSourceGuidance } = {}) {
58
+ if (typeof ctx?.on !== 'function') return null;
59
+
60
+ const startGuidanceContext = (promptCtx) => {
61
+ if (typeof promptCtx?.systemPrompt?.context !== 'function') return null;
62
+ return promptCtx.systemPrompt.context({
63
+ name: IM_SOURCE_GUIDANCE_CONTEXT,
64
+ order: IM_SOURCE_GUIDANCE_ORDER,
65
+ text: (assembly) => registry.get(assembly?.agent?.session?.id) ?? '',
66
+ });
67
+ };
68
+ if (typeof ctx.inject === 'function') {
69
+ // Same gating the outbound artifact tool uses: register once the service
70
+ // exists instead of requiring it at load time.
71
+ ctx.inject(['systemPrompt'], startGuidanceContext);
72
+ } else {
73
+ startGuidanceContext(ctx);
74
+ }
75
+
76
+ const disposeDisposed = ctx.on('agent/disposed', ({ agent }) => {
77
+ const sessionId = agent?.session?.id;
78
+ if (typeof sessionId === 'string') registry.forget(sessionId);
79
+ }, { global: true });
80
+
81
+ const listener = async ({ agent }, next) => {
82
+ const decision = await next();
83
+ if (decision === null || typeof decision !== 'object' || decision.kind !== 'enter') {
84
+ return decision;
85
+ }
86
+ try {
87
+ const rewritten = rewriteInjectedContextMessages(decision.messages, {
88
+ labels: { reply: replyLabel(), source: sourceLabel() },
89
+ ownedGuidance: registry.get(agent?.session?.id),
90
+ });
91
+ return rewritten === null ? decision : { ...decision, messages: rewritten };
92
+ } catch (error) {
93
+ // A failed split must never cost the user their message: the blocks then
94
+ // stay inline, which is the same fallback a Host without Agents uses.
95
+ logger?.warn?.('[dsh-im] unable to split the injected context blocks:', error?.message ?? error);
96
+ return decision;
97
+ }
98
+ };
99
+ const disposePreStep = ctx.on('agent/pre-step', listener, { global: true });
100
+ return () => {
101
+ disposePreStep?.();
102
+ disposeDisposed?.();
103
+ };
104
+ }
@@ -30,7 +30,11 @@ import {
30
30
  } from '../shared/preset-command.mjs';
31
31
  import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
32
32
  import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
33
- import { captureContextEnhancement, enhanceContextContent } from '../shared/context-enhancement.mjs';
33
+ import {
34
+ captureContextEnhancement,
35
+ captureContextEnhancementSource,
36
+ enhanceContextContent,
37
+ } from '../shared/context-enhancement.mjs';
34
38
  import {
35
39
  BatchInputManager,
36
40
  batchInputBusyMessage,
@@ -43,6 +47,7 @@ import {
43
47
  imagePromptUserMessage,
44
48
  } from '../shared/image-prompt.mjs';
45
49
  import {
50
+ InboundFileError,
46
51
  hasInboundFiles,
47
52
  inboundFileUserMessage,
48
53
  prefetchInboundFiles,
@@ -195,6 +200,13 @@ function downloadCodeFor(value) {
195
200
  return nonEmptyString(value?.downloadCode) ?? nonEmptyString(value?.pictureDownloadCode);
196
201
  }
197
202
 
203
+ function dingtalkImageEntries(msgtype, content) {
204
+ if (msgtype === 'picture') return [content];
205
+ if (msgtype !== 'richtext') return [];
206
+ return richTextEntries(content)
207
+ .filter((entry) => String(entry?.type ?? '').toLowerCase() === 'picture');
208
+ }
209
+
198
210
  function dingtalkTimestampMs(value) {
199
211
  const number = typeof value === 'string' && value.trim() ? Number(value) : value;
200
212
  if (!Number.isFinite(number) || number < 0) return null;
@@ -206,24 +218,36 @@ function usefulReplyText(value) {
206
218
  return text && !/^\[interactive card message\]$/iu.test(text) ? text : null;
207
219
  }
208
220
 
209
- function dingtalkReplyReference(message, options) {
210
- const replyEnvelope = message?.text;
211
- if (replyEnvelope?.isReplyMsg !== true) return null;
212
- const replied = replyEnvelope?.repliedMsg;
213
- if (!replied || typeof replied !== 'object') {
214
- return { unavailableReason: 'not-delivered' };
215
- }
216
-
221
+ function dingtalkReplyMessage(message) {
222
+ if (message?.text?.isReplyMsg !== true) return null;
223
+ const replied = message.text.repliedMsg;
224
+ if (!replied || typeof replied !== 'object') return null;
217
225
  const msgtype = nonEmptyString(replied.msgType ?? replied.msgtype)?.toLowerCase() ?? '';
218
226
  const repliedContent = parsedMessageContent({ content: replied.content }) ?? {};
219
- const pseudoMessage = {
227
+ // Quoted richText entries use msgType instead of the direct callback's type.
228
+ const content = msgtype === 'richtext' ? {
229
+ ...repliedContent,
230
+ richText: richTextEntries(repliedContent).map((entry) => ({
231
+ ...entry, type: entry?.type ?? entry?.msgType ?? entry?.msgtype,
232
+ })),
233
+ } : repliedContent;
234
+ return {
220
235
  msgtype,
236
+ robotCode: message.robotCode,
221
237
  text: {
222
238
  content: nonEmptyString(repliedContent.text)
223
239
  ?? (typeof replied.content === 'string' ? replied.content : ''),
224
240
  },
225
- content: repliedContent,
241
+ content,
226
242
  };
243
+ }
244
+
245
+ function dingtalkReplyReference(message, options) {
246
+ if (message?.text?.isReplyMsg !== true) return null;
247
+ const pseudoMessage = dingtalkReplyMessage(message);
248
+ if (!pseudoMessage) return { unavailableReason: 'not-delivered' };
249
+ const replied = message.text.repliedMsg;
250
+ const { msgtype, content: repliedContent } = pseudoMessage;
227
251
  const normalized = dingtalkInboundMessage(pseudoMessage, options);
228
252
  let attachments = [];
229
253
  if (msgtype === 'picture') {
@@ -232,8 +256,7 @@ function dingtalkReplyReference(message, options) {
232
256
  const name = nonEmptyString(repliedContent.fileName ?? repliedContent.file_name);
233
257
  attachments = [{ kind: 'file', ...(name ? { name } : {}) }];
234
258
  } else if (msgtype === 'richtext') {
235
- attachments = richTextEntries(repliedContent)
236
- .filter((entry) => String(entry?.type ?? '').toLowerCase() === 'picture')
259
+ attachments = dingtalkImageEntries(msgtype, repliedContent)
237
260
  .map(() => ({ kind: 'image' }));
238
261
  } else if (msgtype === 'voice' || msgtype === 'audio') {
239
262
  attachments = [{ kind: 'audio' }];
@@ -278,6 +301,43 @@ function dingtalkReplyReference(message, options) {
278
301
  };
279
302
  }
280
303
 
304
+ // Resolve only the immediate quote, after command/interaction routing has finished.
305
+ function prepareDingtalkReplyAttachments(message, promptMessage, options) {
306
+ const quoted = dingtalkReplyMessage(message);
307
+ if (!quoted) return promptMessage;
308
+ if (['audio', 'voice', 'video'].includes(quoted.msgtype)) quoted.msgtype = 'file';
309
+ const imageCodes = dingtalkImageEntries(quoted.msgtype, quoted.content).map(downloadCodeFor);
310
+ const fileCodes = quoted.msgtype === 'file' ? [downloadCodeFor(quoted.content)] : [];
311
+ if ([...imageCodes, ...fileCodes].some((code) => !code)) {
312
+ throw new InboundFileError(
313
+ 'dingtalk-quoted-attachment-unavailable',
314
+ 'DingTalk did not deliver a download reference for the quoted attachment.',
315
+ t('钉钉未提供引用附件的下载信息,无法读取原附件。请直接重新发送附件后再提问。'),
316
+ );
317
+ }
318
+ if (imageCodes.length === 0 && fileCodes.length === 0) return promptMessage;
319
+ const currentType = String(message?.msgtype ?? '').toLowerCase();
320
+ const currentContent = parsedMessageContent(message);
321
+ const seen = new Set([
322
+ ...dingtalkImageEntries(currentType, currentContent).map(downloadCodeFor),
323
+ ...(currentType === 'file' ? [downloadCodeFor(currentContent)] : []),
324
+ ].filter(Boolean));
325
+ const take = (code) => {
326
+ if (seen.has(code)) return false;
327
+ seen.add(code);
328
+ return true;
329
+ };
330
+ const normalized = dingtalkInboundMessage(quoted, options);
331
+ const images = normalized.images.filter((_, index) => take(imageCodes[index]));
332
+ const files = normalized.files.filter((_, index) => take(fileCodes[index]));
333
+ const prefetched = prefetchInboundFiles({ files }, { signal: options.signal });
334
+ return {
335
+ ...promptMessage,
336
+ images: [...promptMessage.images, ...images],
337
+ files: [...promptMessage.files, ...prefetched.files],
338
+ };
339
+ }
340
+
281
341
  /** Normalize DingTalk picture and richText callbacks into lazy image references. */
282
342
  export function dingtalkInboundMessage(message, {
283
343
  api,
@@ -291,17 +351,7 @@ export function dingtalkInboundMessage(message, {
291
351
  const text = msgtype === 'text'
292
352
  ? nonEmptyString(message?.text?.content) ?? ''
293
353
  : richEntries.map(richTextEntryText).filter(Boolean).join('\n');
294
- const imageCodes = [];
295
- if (msgtype === 'picture') {
296
- const code = downloadCodeFor(content);
297
- if (code) imageCodes.push(code);
298
- } else if (msgtype === 'richtext') {
299
- for (const entry of richEntries) {
300
- if (String(entry?.type ?? '').toLowerCase() !== 'picture') continue;
301
- const code = downloadCodeFor(entry);
302
- if (code) imageCodes.push(code);
303
- }
304
- }
354
+ const imageCodes = dingtalkImageEntries(msgtype, content).map(downloadCodeFor).filter(Boolean);
305
355
  const fileCode = msgtype === 'file' ? downloadCodeFor(content) : null;
306
356
  const replyTo = dingtalkReplyReference(message, {
307
357
  api,
@@ -838,6 +888,17 @@ export class DingtalkHarnessBridge {
838
888
  signal: this.#signal, isDirect: String(message.conversationType) === '1',
839
889
  pendingInteraction: this.#pendingInteractions.has(key) || this.#approvals.hasPending(key),
840
890
  control: { owner: this, key }, deferredDelivery: this.#deferred,
891
+ enhancement: captureContextEnhancementSource(
892
+ this.#contextEnhancement,
893
+ String(message.conversationType) === '1' ? 'direct' : 'group',
894
+ () => ({
895
+ channel: 'dingtalk',
896
+ senderId: senderStaffId(message),
897
+ senderName: message.senderNick,
898
+ conversationTitle: message.conversationTitle,
899
+ chatId: message.conversationId,
900
+ }),
901
+ ),
841
902
  };
842
903
  const access = evaluateInboundAccess(this.#accessPolicy, {
843
904
  conversationType: options.isDirect ? 'direct' : 'group',
@@ -1102,6 +1163,17 @@ export class DingtalkHarnessBridge {
1102
1163
  || this.#approvals.hasPending(key),
1103
1164
  control: { owner: this, key },
1104
1165
  deferredDelivery: this.#deferred,
1166
+ enhancement: captureContextEnhancementSource(
1167
+ this.#contextEnhancement,
1168
+ String(message.conversationType) === '1' ? 'direct' : 'group',
1169
+ () => ({
1170
+ channel: 'dingtalk',
1171
+ senderId: senderStaffId(message),
1172
+ senderName: message.senderNick,
1173
+ conversationTitle: message.conversationTitle,
1174
+ chatId: message.conversationId,
1175
+ }),
1176
+ ),
1105
1177
  },
1106
1178
  );
1107
1179
  if (result?.stopped) {
@@ -1261,8 +1333,12 @@ export class DingtalkHarnessBridge {
1261
1333
  return;
1262
1334
  }
1263
1335
 
1264
- let content = hasImages || hasReply
1265
- ? await promptContentForInboundMessage(promptMessage, { signal: this.#signal })
1336
+ const modelMessage = prepareDingtalkReplyAttachments(message, promptMessage, {
1337
+ api: this.#api, clientId: this.#clientId, clientSecret: this.#clientSecret,
1338
+ signal: this.#signal,
1339
+ });
1340
+ let content = hasInboundImages(modelMessage) || hasReply
1341
+ ? await promptContentForInboundMessage(modelMessage, { signal: this.#signal })
1266
1342
  : undefined;
1267
1343
  const snapshot = this.#acceptedMessageIds.get(messageId);
1268
1344
  let contextEnhanced = false;
@@ -1300,6 +1376,7 @@ export class DingtalkHarnessBridge {
1300
1376
  text,
1301
1377
  content,
1302
1378
  titleText: batchSubmission?.title,
1379
+ sourceGuidance: snapshot?.config?.guidance,
1303
1380
  contextEnhanced,
1304
1381
  createOptions: { signal: this.#signal },
1305
1382
  existsOptions: { signal: this.#signal },
@@ -1317,7 +1394,7 @@ export class DingtalkHarnessBridge {
1317
1394
  requiresMention: String(message.conversationType) === '2',
1318
1395
  }),
1319
1396
  onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
1320
- files: promptMessage.files,
1397
+ files: modelMessage.files,
1321
1398
  },
1322
1399
  });
1323
1400
  if (batchSubmission) {
@@ -60,7 +60,11 @@ import {
60
60
  } from '../shared/workspace-command.mjs';
61
61
  import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
62
62
  import { createDeferredDeliveryCoordinator, deferredOutcomeText } from '../shared/deferred-delivery-coordinator.mjs';
63
- import { captureContextEnhancement, enhanceContextContent } from '../shared/context-enhancement.mjs';
63
+ import {
64
+ captureContextEnhancement,
65
+ captureContextEnhancementSource,
66
+ enhanceContextContent,
67
+ } from '../shared/context-enhancement.mjs';
64
68
  import { deliverOutboundArtifacts } from '../shared/semantic/artifact-delivery.mjs';
65
69
  import {
66
70
  createDeliveryReceipt,
@@ -1416,6 +1420,16 @@ export class FeishuHarnessBridge {
1416
1420
  hasFiles: hasInboundFiles(message),
1417
1421
  pendingInteraction: this.#hasPendingInteraction(key),
1418
1422
  control: { owner: this, key },
1423
+ enhancement: captureContextEnhancementSource(
1424
+ this.#contextEnhancement,
1425
+ event.message.chat_type === 'p2p' ? 'direct' : 'group',
1426
+ () => ({
1427
+ channel: 'feishu',
1428
+ senderId: senderOpenId(event),
1429
+ chatId: event.message.chat_id,
1430
+ threadId: event.message.thread_id,
1431
+ }),
1432
+ ),
1419
1433
  },
1420
1434
  );
1421
1435
  if (result?.stopped) {
@@ -2475,7 +2489,7 @@ export class FeishuHarnessBridge {
2475
2489
  await this.#sendCard(chatId, customSteerCard(), { key, updateMessageId: messageId, replyTo: messageId });
2476
2490
  return;
2477
2491
  }
2478
- await this.#sendSteer({ key, chatId, messageId }, raw);
2492
+ await this.#sendSteer({ key, chatId, messageId, actor }, raw);
2479
2493
  return;
2480
2494
  }
2481
2495
  if (action === 'presets') {
@@ -3201,11 +3215,26 @@ export class FeishuHarnessBridge {
3201
3215
  */
3202
3216
  async #sendSteer(entry, text) {
3203
3217
  const { key, chatId } = entry;
3218
+ // Card routes carry the conversation key, not the raw event, so the topic
3219
+ // id is recovered from the key the channel itself minted.
3220
+ const threadId = typeof key === 'string'
3221
+ ? /(?:^|:)thread:(.+)$/u.exec(key)?.[1]
3222
+ : undefined;
3204
3223
  const result = await runControlCommand(
3205
3224
  `/steer ${text}`, this.#harness, this.#state, key, {
3206
3225
  signal: this.#signal,
3207
3226
  pendingInteraction: this.#hasPendingInteraction(key),
3208
3227
  control: { owner: this, key },
3228
+ enhancement: captureContextEnhancementSource(
3229
+ this.#contextEnhancement,
3230
+ typeof key === 'string' && key.startsWith('p2p:') ? 'direct' : 'group',
3231
+ () => ({
3232
+ channel: 'feishu',
3233
+ senderId: entry.actor ?? entry.operatorOpenId,
3234
+ chatId,
3235
+ threadId,
3236
+ }),
3237
+ ),
3209
3238
  },
3210
3239
  );
3211
3240
  await this.#send(chatId, result?.message || t('已提交补充指令。'), { replyTo: entry.messageId ?? null });
@@ -4964,6 +4993,7 @@ export class FeishuHarnessBridge {
4964
4993
  text,
4965
4994
  content,
4966
4995
  titleText: event.batchSubmission?.title,
4996
+ sourceGuidance: snapshot?.config?.guidance,
4967
4997
  contextEnhanced,
4968
4998
  createOptions: { signal: this.#signal },
4969
4999
  existsOptions: { signal: this.#signal },
@@ -5207,6 +5237,7 @@ export class FeishuHarnessBridge {
5207
5237
  text,
5208
5238
  content,
5209
5239
  titleText: event.batchSubmission?.title,
5240
+ sourceGuidance: snapshot?.config?.guidance,
5210
5241
  contextEnhanced,
5211
5242
  createOptions: { signal: this.#signal },
5212
5243
  existsOptions: { signal: this.#signal },
@@ -5287,6 +5318,7 @@ export class FeishuHarnessBridge {
5287
5318
  text,
5288
5319
  content,
5289
5320
  titleText: event.batchSubmission?.title,
5321
+ sourceGuidance: snapshot?.config?.guidance,
5290
5322
  contextEnhanced,
5291
5323
  createOptions: { signal: this.#signal },
5292
5324
  existsOptions: { signal: this.#signal },
@@ -5358,6 +5390,7 @@ export class FeishuHarnessBridge {
5358
5390
  text,
5359
5391
  content,
5360
5392
  titleText: event.batchSubmission?.title,
5393
+ sourceGuidance: snapshot?.config?.guidance,
5361
5394
  contextEnhanced,
5362
5395
  createOptions: { signal: this.#signal },
5363
5396
  existsOptions: { signal: this.#signal },
@@ -22,7 +22,11 @@ import {
22
22
  runPresetCommand,
23
23
  } from '../shared/preset-command.mjs';
24
24
  import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
25
- import { captureContextEnhancement, enhanceContextContent } from '../shared/context-enhancement.mjs';
25
+ import {
26
+ captureContextEnhancement,
27
+ captureContextEnhancementSource,
28
+ enhanceContextContent,
29
+ } from '../shared/context-enhancement.mjs';
26
30
  import {
27
31
  BatchInputManager,
28
32
  batchInputBusyMessage,
@@ -759,7 +763,17 @@ export class QqHarnessBridge {
759
763
  return { message: t('当前任务仍在运行,请先停止任务或等待任务完成后再执行此操作。') };
760
764
  }
761
765
  const options = { signal: this.#signal, isDirect: message.kind === 'c2c', pendingInteraction,
762
- control: { owner: this, key }, deferredDelivery: this.#deferred };
766
+ control: { owner: this, key }, deferredDelivery: this.#deferred,
767
+ enhancement: captureContextEnhancementSource(
768
+ this.#contextEnhancement,
769
+ message.kind === 'c2c' ? 'direct' : 'group',
770
+ () => ({
771
+ channel: 'qq',
772
+ senderId: nonEmptyString(message.senderId),
773
+ senderName: message.kind === 'group' ? message.senderName : undefined,
774
+ chatId: message.kind === 'group' ? message.groupOpenid : message.senderId,
775
+ }),
776
+ ) };
763
777
  // Existing runners own all Host mutations and control authorization.
764
778
  const execute = async () => {
765
779
  this.#signal?.throwIfAborted();
@@ -824,6 +838,16 @@ export class QqHarnessBridge {
824
838
  || this.#approvals.hasPending(key),
825
839
  control: { owner: this, key },
826
840
  deferredDelivery: this.#deferred,
841
+ enhancement: captureContextEnhancementSource(
842
+ this.#contextEnhancement,
843
+ message.kind === 'c2c' ? 'direct' : 'group',
844
+ () => ({
845
+ channel: 'qq',
846
+ senderId: nonEmptyString(message.senderId),
847
+ senderName: message.kind === 'group' ? message.senderName : undefined,
848
+ chatId: message.kind === 'group' ? message.groupOpenid : message.senderId,
849
+ }),
850
+ ),
827
851
  });
828
852
  if (result?.stopped) {
829
853
  await Promise.allSettled([
@@ -1046,6 +1070,7 @@ export class QqHarnessBridge {
1046
1070
  text,
1047
1071
  content,
1048
1072
  titleText: batchSubmission?.title,
1073
+ sourceGuidance: snapshot?.config?.guidance,
1049
1074
  contextEnhanced,
1050
1075
  createOptions: { signal: this.#signal },
1051
1076
  existsOptions: { signal: this.#signal },
@@ -4,6 +4,23 @@ export const CONTEXT_ENHANCEMENT_FIELDS = Object.freeze([
4
4
  'chatId', 'threadId', 'botId',
5
5
  ]);
6
6
 
7
+ /**
8
+ * Tag grammar of the injected context prefix. The producer here and the
9
+ * Host-side splitter in `injected-context.mjs` share these literals, so the
10
+ * prefix a channel writes can never drift from the parser that pairs it.
11
+ */
12
+ export const INJECTED_CONTEXT_TAGS = Object.freeze({
13
+ sourceOpen: '<dsh_im_source>',
14
+ sourceClose: '</dsh_im_source>',
15
+ guidanceOpen: '<dsh_im_source_guidance>',
16
+ guidanceClose: '</dsh_im_source_guidance>',
17
+ replyOpen: '<dsh_im_reply_to>',
18
+ replyClose: '</dsh_im_reply_to>',
19
+ });
20
+
21
+ /** Separator the producer joins prefix blocks with, and the splitter consumes. */
22
+ export const INJECTED_CONTEXT_SEPARATOR = '\n\n';
23
+
7
24
  export const CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH = 8_000;
8
25
  export const CONTEXT_GROUP_GUIDANCE_EXAMPLE = `仅依据当前消息的 <dsh_im_source> 中实际提供的字段理解来源;没有提供的字段不要猜测或补全。
9
26
  当前消息来自群聊,请使用严肃、克制、简洁的表达方式。`;
@@ -138,6 +155,26 @@ export function captureContextEnhancement(provider, conversationType) {
138
155
  }
139
156
  }
140
157
 
158
+ /**
159
+ * Capture the enhancement one prompt replays, together with the source factory
160
+ * that fills its selected fields.
161
+ *
162
+ * Ordinary messages snapshot this when they are accepted, so a queued message
163
+ * keeps the settings it arrived under. A control command is never queued, so it
164
+ * captures at the moment it runs -- and it must, because the source fields of a
165
+ * steering instruction belong to whoever issued it, not to the message that
166
+ * opened the turn.
167
+ *
168
+ * @param provider - the bot's enhancement provider.
169
+ * @param conversationType - the inbound message's scope.
170
+ * @param source - factory for the currently selected source fields.
171
+ * @returns the enhancement to apply, or null when the scope is off.
172
+ */
173
+ export function captureContextEnhancementSource(provider, conversationType, source) {
174
+ const snapshot = captureContextEnhancement(provider, conversationType);
175
+ return snapshot === null ? null : Object.freeze({ snapshot, source });
176
+ }
177
+
141
178
  function sourceString(value, field) {
142
179
  if (field === 'senderId' && (typeof value === 'bigint' || Number.isFinite(value))) {
143
180
  value = String(value);
@@ -165,7 +202,7 @@ function sourceBlock(snapshot, sourceFactory) {
165
202
  const json = JSON.stringify(projected).replace(/[<>&]/g, (character) => ({
166
203
  '<': '\\u003c', '>': '\\u003e', '&': '\\u0026',
167
204
  })[character]);
168
- return `<dsh_im_source>${json}</dsh_im_source>`;
205
+ return `${INJECTED_CONTEXT_TAGS.sourceOpen}${json}${INJECTED_CONTEXT_TAGS.sourceClose}`;
169
206
  }
170
207
 
171
208
  function guidanceBlock(guidance) {
@@ -173,7 +210,7 @@ function guidanceBlock(guidance) {
173
210
  const body = guidance.replace(/<\/?dsh_im_source_guidance\b[^>]*(?:>|$)/gi, (tag) => (
174
211
  tag.replace(/</g, '&lt;').replace(/>/g, '&gt;')
175
212
  ));
176
- return `<dsh_im_source_guidance>\n${body}\n</dsh_im_source_guidance>`;
213
+ return `${INJECTED_CONTEXT_TAGS.guidanceOpen}\n${body}\n${INJECTED_CONTEXT_TAGS.guidanceClose}`;
177
214
  }
178
215
 
179
216
  /** Add one text prefix; never inspect sources, format or copy content when off. */
@@ -183,7 +220,7 @@ export function enhanceContextContent(content, snapshot, sourceFactory) {
183
220
  const blocks = [sourceBlock(snapshot, sourceFactory), guidanceBlock(snapshot.config.guidance)]
184
221
  .filter(Boolean);
185
222
  if (blocks.length === 0) return content;
186
- const prefix = blocks.join('\n\n');
223
+ const prefix = blocks.join(INJECTED_CONTEXT_SEPARATOR);
187
224
  if (typeof content === 'string') return `${prefix}\n\n${content}`;
188
225
  if (Array.isArray(content)) return [{ type: 'text', text: prefix }, ...content];
189
226
  return content;
@@ -1,3 +1,4 @@
1
+ import { enhanceContextContent } from './context-enhancement.mjs';
1
2
  import { t } from './i18n.mjs';
2
3
  import manifest from '../../../package.json' with { type: 'json' };
3
4
 
@@ -41,6 +42,7 @@ export async function runControlCommand(text, harness, state, key, {
41
42
  pendingInteraction = false,
42
43
  control,
43
44
  deferredDelivery,
45
+ enhancement,
44
46
  } = {}) {
45
47
  if (!isControlCommand(text)) return null;
46
48
  const command = text.trim();
@@ -93,8 +95,13 @@ export async function runControlCommand(text, harness, state, key, {
93
95
  if (typeof session.steerActiveTurn !== 'function') {
94
96
  throw new TypeError('Harness session does not support steering active turns');
95
97
  }
98
+ // A mid-turn correction carries the same provenance as the message that
99
+ // opened the turn, so a group member who steers is identified too.
100
+ const steering = enhancement
101
+ ? enhanceContextContent(instruction, enhancement.snapshot, enhancement.source)
102
+ : instruction;
96
103
  const steered = await session.steerActiveTurn(
97
- instruction,
104
+ steering,
98
105
  control,
99
106
  requestOptions(signal),
100
107
  );
@@ -13,6 +13,7 @@ import {
13
13
  imageFileSourcesFromContent,
14
14
  isModelImageRejection,
15
15
  } from './image-prompt.mjs';
16
+ import { imSourceGuidance } from './im-source-guidance.mjs';
16
17
  import { outboundArtifactRegistry } from './semantic/artifact.mjs';
17
18
  import { t } from './i18n.mjs';
18
19
  import { watchHarnessMux } from './harness-mux.mjs';
@@ -1508,6 +1509,12 @@ export class HarnessClient {
1508
1509
  if (!Array.isArray(content) || content.length === 0) {
1509
1510
  throw new TypeError('Harness prompt content is required');
1510
1511
  }
1512
+ // Publish the guidance the channel's own captured settings produced, so
1513
+ // the Host materializes it once per Session as prompt context instead of
1514
+ // per user message. It is passed in and never parsed back out of
1515
+ // `content`: the prompt also carries whatever the user typed, and a
1516
+ // message that merely looks like a guidance block is not configuration.
1517
+ imSourceGuidance.publish(sessionId, options.sourceGuidance);
1511
1518
  const clientTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
1512
1519
  const sendPrompt = (promptContent) => this.rpc('session.prompt', {
1513
1520
  sessionId,
@@ -16,6 +16,7 @@ export default {
16
16
 
17
17
  '钉钉未能换取图片下载地址,请重新发送;若持续失败,请检查机器人的“企业内机器人发送消息权限”。': 'DingTalk could not provide the image download address. Please resend; if it keeps failing, check the bot\'s "Send messages as an internal robot" permission.',
18
18
  '钉钉没有返回图片下载地址,请重新发送。': 'DingTalk did not return an image download address. Please resend.',
19
+ '钉钉未提供引用附件的下载信息,无法读取原附件。请直接重新发送附件后再提问。': 'DingTalk did not provide download information for the quoted attachment, so it cannot be read. Please resend the attachment directly with your question.',
19
20
  '钉钉返回的图片临时地址无法读取,请重新发送。': 'The temporary image address returned by DingTalk could not be read. Please resend.',
20
21
  '结果文件「{name}」发送结果未能确认,请先检查聊天内是否已收到,不要立即重试。': 'Delivery of the result file "{name}" could not be confirmed. Please check whether it already arrived in the chat before retrying.',
21
22
  '结果文件「{name}」已生成,但钉钉应用或机器人缺少文件消息权限。请开通应用 qyapi_base 权限,并确认机器人具备文件消息发送能力。': 'Result file "{name}" was generated, but the DingTalk app or bot lacks file message permission. Enable the app\'s qyapi_base permission and make sure the bot can send file messages.',
@@ -6,6 +6,7 @@ export default {
6
6
  '微信已连接 DeepSeek Harness。': 'WeChat is connected to DeepSeek Harness.',
7
7
  '结果文件「{name}」已生成,但微信机器人当前没有文件消息发送权限,请检查机器人文件消息能力。': 'The result file "{name}" was generated, but the WeChat bot currently has no permission to send file messages. Please check the bot\'s file messaging capability.',
8
8
  '结果文件「{name}」超过当前微信会话可发送的文件大小,未发送。': 'The result file "{name}" exceeds the file size limit of the current WeChat conversation and was not sent.',
9
+ '结果文件「{name}」上传微信时长时间没有进展,已超时,文件尚未发送。请检查网络后重试,或压缩、拆分文件后发送。': 'The upload of "{name}" to WeChat stalled and timed out. The file has not been sent. Check your network and retry, or compress or split the file before sending.',
9
10
  '结果文件「{name}」暂时被微信限流,未能发送,请稍后重试。': 'The result file "{name}" was temporarily rate-limited by WeChat and was not sent. Please try again later.',
10
11
  '结果文件「{name}」已生成,但微信拒绝了该文件消息。': 'The result file "{name}" was generated, but WeChat rejected the file message.',
11
12
  '结果文件「{name}」已生成,但暂时未能通过微信发送,请稍后重试。': 'The result file "{name}" was generated but could not be sent via WeChat right now. Please try again later.',
@@ -72,6 +73,7 @@ export default {
72
73
  '微信文件上传失败(HTTP {status})。': 'The WeChat file upload failed (HTTP {status}).',
73
74
  '微信文件上传响应缺少下载参数。': 'The WeChat file upload response is missing the download parameter.',
74
75
  '微信文件上传失败。': 'The WeChat file upload failed.',
76
+ '微信文件上传长时间没有进展,已超时。': 'The WeChat file upload stalled and timed out.',
75
77
  '拒绝访问不受信任的微信服务地址。': 'Refusing to access an untrusted WeChat service URL.',
76
78
  '微信服务请求失败(HTTP {status})。': 'The WeChat service request failed (HTTP {status}).',
77
79
  '微信服务返回了无法解析的响应。': 'The WeChat service returned an unparseable response.',