@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.
- package/README.en.md +21 -9
- package/README.md +21 -9
- package/lib/client.js +116 -21
- package/lib/index.js +288 -287
- package/package.json +17 -1
- package/plugin-src/client/channel-logos.js +18 -5
- package/plugin-src/client/channels/imessage/styles.js +1 -1
- package/plugin-src/client/channels/slack/styles.js +1 -1
- package/plugin-src/client/channels/weixin/connection-error.js +4 -1
- package/plugin-src/client/i18n.js +8 -0
- package/plugin-src/client/model-setting.js +4 -2
- package/plugin-src/client/session-channel-logos.js +1 -2
- package/plugin-src/client/styles.js +3 -2
- package/plugin-src/host/channels/qq/production.mjs +1 -1
- package/plugin-src/host/channels/qq/rpc.mjs +2 -1
- package/plugin-src/host/index.mjs +7 -0
- package/plugin-src/host/injected-context.mjs +104 -0
- package/plugin-src/host/modern-harness-api.mjs +91 -3
- package/scripts/verify-model-setting.mjs +4 -1
- package/scripts/verify-package.mjs +3 -1
- package/src/channels/dingtalk/dingtalk-bridge.mjs +105 -27
- package/src/channels/discord/discord-runtime.mjs +4 -1
- package/src/channels/feishu/bridge.mjs +44 -3
- package/src/channels/feishu/feishu-channel.mjs +1 -1
- package/src/channels/qq/qq-bridge.mjs +36 -3
- package/src/channels/qq/qq-controller.mjs +11 -5
- package/src/channels/qq/state-error.mjs +17 -0
- package/src/channels/qq/state-store.mjs +35 -9
- package/src/channels/shared/batch-input.mjs +22 -2
- package/src/channels/shared/bot-workspace-store.mjs +46 -26
- package/src/channels/shared/config-read-error.mjs +24 -0
- package/src/channels/shared/context-enhancement.mjs +40 -3
- package/src/channels/shared/control-command.mjs +8 -1
- package/src/channels/shared/harness-client.mjs +20 -12
- package/src/channels/shared/harness-question.mjs +10 -2
- package/src/channels/shared/i18n-en/dingtalk.mjs +1 -0
- package/src/channels/shared/i18n-en/qq.mjs +3 -0
- package/src/channels/shared/i18n-en/shared-a.mjs +11 -0
- package/src/channels/shared/i18n-en/shared-c.mjs +2 -0
- package/src/channels/shared/i18n-en/weixin.mjs +2 -0
- package/src/channels/shared/im-source-guidance.mjs +65 -0
- package/src/channels/shared/injected-context.mjs +362 -0
- package/src/channels/shared/semantic/artifact.mjs +4 -4
- package/src/channels/shared/semantic/reply-reference.mjs +2 -1
- package/src/channels/shared/text-harness-bridge.mjs +221 -5
- package/src/channels/shared/token-config-store.mjs +23 -8
- package/src/channels/shared/workspace-session.mjs +16 -1
- package/src/channels/slack/slack-runtime.mjs +3 -1
- package/src/channels/telegram/telegram-api.mjs +62 -2
- package/src/channels/telegram/telegram-bridge.mjs +66 -1
- package/src/channels/telegram/telegram-rich-message.mjs +6 -4
- package/src/channels/telegram/telegram-runtime.mjs +128 -6
- package/src/channels/wecom/wecom-bridge.mjs +15 -1
- package/src/channels/wecom-app/config-store.mjs +3 -1
- package/src/channels/wecom-app/wecom-app-bridge.mjs +12 -1
- package/src/channels/weixin/config-store.mjs +24 -15
- package/src/channels/weixin/connection-error.en.mjs +21 -0
- package/src/channels/weixin/connection-error.mjs +21 -6
- package/src/channels/weixin/diagnostic-details.mjs +24 -1
- package/src/channels/weixin/weixin-api.mjs +52 -13
- 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: '
|
|
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
|
|
134
|
+
return `${INJECTED_CONTEXT_TAGS.replyOpen}${json}${INJECTED_CONTEXT_TAGS.replyClose}`;
|
|
134
135
|
}
|
|
135
136
|
|
|
136
137
|
export function hasReplyReference(message) {
|