@xmanrui/dsh-im 4.20.2 → 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.
Files changed (61) hide show
  1. package/README.en.md +21 -9
  2. package/README.md +21 -9
  3. package/lib/client.js +116 -21
  4. package/lib/index.js +288 -287
  5. package/package.json +17 -1
  6. package/plugin-src/client/channel-logos.js +18 -5
  7. package/plugin-src/client/channels/imessage/styles.js +1 -1
  8. package/plugin-src/client/channels/slack/styles.js +1 -1
  9. package/plugin-src/client/channels/weixin/connection-error.js +4 -1
  10. package/plugin-src/client/i18n.js +8 -0
  11. package/plugin-src/client/model-setting.js +4 -2
  12. package/plugin-src/client/session-channel-logos.js +1 -2
  13. package/plugin-src/client/styles.js +3 -2
  14. package/plugin-src/host/channels/qq/production.mjs +1 -1
  15. package/plugin-src/host/channels/qq/rpc.mjs +2 -1
  16. package/plugin-src/host/index.mjs +7 -0
  17. package/plugin-src/host/injected-context.mjs +104 -0
  18. package/plugin-src/host/modern-harness-api.mjs +91 -3
  19. package/scripts/verify-model-setting.mjs +4 -1
  20. package/scripts/verify-package.mjs +3 -1
  21. package/src/channels/dingtalk/dingtalk-bridge.mjs +105 -27
  22. package/src/channels/discord/discord-runtime.mjs +4 -1
  23. package/src/channels/feishu/bridge.mjs +44 -3
  24. package/src/channels/feishu/feishu-channel.mjs +1 -1
  25. package/src/channels/qq/qq-bridge.mjs +36 -3
  26. package/src/channels/qq/qq-controller.mjs +11 -5
  27. package/src/channels/qq/state-error.mjs +17 -0
  28. package/src/channels/qq/state-store.mjs +35 -9
  29. package/src/channels/shared/batch-input.mjs +22 -2
  30. package/src/channels/shared/bot-workspace-store.mjs +46 -26
  31. package/src/channels/shared/config-read-error.mjs +24 -0
  32. package/src/channels/shared/context-enhancement.mjs +40 -3
  33. package/src/channels/shared/control-command.mjs +8 -1
  34. package/src/channels/shared/harness-client.mjs +20 -12
  35. package/src/channels/shared/harness-question.mjs +10 -2
  36. package/src/channels/shared/i18n-en/dingtalk.mjs +1 -0
  37. package/src/channels/shared/i18n-en/qq.mjs +3 -0
  38. package/src/channels/shared/i18n-en/shared-a.mjs +11 -0
  39. package/src/channels/shared/i18n-en/shared-c.mjs +2 -0
  40. package/src/channels/shared/i18n-en/weixin.mjs +2 -0
  41. package/src/channels/shared/im-source-guidance.mjs +65 -0
  42. package/src/channels/shared/injected-context.mjs +362 -0
  43. package/src/channels/shared/semantic/artifact.mjs +4 -4
  44. package/src/channels/shared/semantic/reply-reference.mjs +2 -1
  45. package/src/channels/shared/text-harness-bridge.mjs +221 -5
  46. package/src/channels/shared/token-config-store.mjs +23 -8
  47. package/src/channels/shared/workspace-session.mjs +16 -1
  48. package/src/channels/slack/slack-runtime.mjs +3 -1
  49. package/src/channels/telegram/telegram-api.mjs +62 -2
  50. package/src/channels/telegram/telegram-bridge.mjs +66 -1
  51. package/src/channels/telegram/telegram-rich-message.mjs +6 -4
  52. package/src/channels/telegram/telegram-runtime.mjs +128 -6
  53. package/src/channels/wecom/wecom-bridge.mjs +15 -1
  54. package/src/channels/wecom-app/config-store.mjs +3 -1
  55. package/src/channels/wecom-app/wecom-app-bridge.mjs +12 -1
  56. package/src/channels/weixin/config-store.mjs +24 -15
  57. package/src/channels/weixin/connection-error.en.mjs +21 -0
  58. package/src/channels/weixin/connection-error.mjs +21 -6
  59. package/src/channels/weixin/diagnostic-details.mjs +24 -1
  60. package/src/channels/weixin/weixin-api.mjs +52 -13
  61. package/src/channels/weixin/weixin-bridge.mjs +14 -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
+ }
@@ -371,7 +371,7 @@ export class OutboundArtifactRegistry {
371
371
  const agent = exec?.agent;
372
372
  const sessionId = agent?.session?.header?.id;
373
373
  const workspace = agent?.session?.header?.cwd;
374
- const turn = currentTurn(agent);
374
+ const turn = this.#openTurns.get(sessionId) ?? currentTurn(agent);
375
375
  if (typeof sessionId !== 'string' || !sessionId
376
376
  || typeof workspace !== 'string' || !workspace || turn === null) {
377
377
  throw artifactError(
@@ -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) {