@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.
@@ -0,0 +1,65 @@
1
+ // Session-keyed source guidance for the current conversation.
2
+ //
3
+ // The prompt RPC carries no message source, so a channel publishes the guidance
4
+ // it would otherwise repeat inside every user message when it dispatches the
5
+ // prompt. The Host materializes it once per session as dynamic prompt context
6
+ // (`systemPrompt.context`), which appends a durable snapshot only when the
7
+ // rendered text changes. The registry is module-level because one process owns
8
+ // exactly one, the same shape `outboundArtifactRegistry` uses.
9
+
10
+ /** Prompt-context name the Host materializes this guidance under. */
11
+ export const IM_SOURCE_GUIDANCE_CONTEXT = 'dsh-im:source-guidance';
12
+
13
+ /** Contexts are joined in ascending order; this one follows the policy facts. */
14
+ export const IM_SOURCE_GUIDANCE_ORDER = 125;
15
+
16
+ /** Bound for one session's guidance; the settings cap is the same size. */
17
+ const GUIDANCE_MAX_LENGTH = 8_000;
18
+
19
+ /** Bound on retained sessions, so an abandoned conversation cannot grow it. */
20
+ const MAX_SESSIONS = 1_024;
21
+
22
+ class ImSourceGuidanceRegistry {
23
+ #bySession = new Map();
24
+
25
+ /**
26
+ * Publish the guidance in force for one session. Empty guidance clears it, so
27
+ * a conversation that turns enhancement off stops contributing a snapshot.
28
+ * @param sessionId - the Session the prompt was dispatched to.
29
+ * @param guidance - the scope's guidance text, or empty when none applies.
30
+ */
31
+ publish(sessionId, guidance) {
32
+ if (typeof sessionId !== 'string' || !sessionId) return;
33
+ const text = typeof guidance === 'string' ? guidance.slice(0, GUIDANCE_MAX_LENGTH) : '';
34
+ // Delete first so the re-inserted key is the most recently used one.
35
+ this.#bySession.delete(sessionId);
36
+ if (!text.trim()) return;
37
+ this.#bySession.set(sessionId, text);
38
+ while (this.#bySession.size > MAX_SESSIONS) {
39
+ this.#bySession.delete(this.#bySession.keys().next().value);
40
+ }
41
+ }
42
+
43
+ /**
44
+ * @param sessionId - a Session id, or anything else.
45
+ * @returns the guidance in force for that session, or undefined.
46
+ */
47
+ get(sessionId) {
48
+ return typeof sessionId === 'string' ? this.#bySession.get(sessionId) : undefined;
49
+ }
50
+
51
+ /**
52
+ * Drop one session's guidance.
53
+ * @param sessionId - the Session leaving the registry.
54
+ */
55
+ forget(sessionId) {
56
+ if (typeof sessionId === 'string') this.#bySession.delete(sessionId);
57
+ }
58
+
59
+ /** @returns how many sessions currently carry guidance. */
60
+ get size() {
61
+ return this.#bySession.size;
62
+ }
63
+ }
64
+
65
+ export const imSourceGuidance = new ImSourceGuidanceRegistry();
@@ -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 },