@xmanrui/dsh-im 4.38.2 → 4.39.0
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/lib/client.js +4 -2
- package/lib/index.js +311 -306
- package/package.json +1 -1
- package/plugin-src/host/injected-context.mjs +13 -1
- package/src/channels/matrix/matrix-api.mjs +34 -0
- package/src/channels/matrix/matrix-config-store.mjs +50 -2
- package/src/channels/matrix/matrix-normalize.mjs +106 -14
- package/src/channels/matrix/matrix-room-history.mjs +240 -8
- package/src/channels/matrix/matrix-runtime.mjs +563 -3
- package/src/channels/shared/context-enhancement.mjs +2 -0
- package/src/channels/shared/conversation/ambient-provider.mjs +115 -0
- package/src/channels/shared/conversation/context-builder.mjs +148 -0
- package/src/channels/shared/conversation/reply-context.mjs +95 -0
- package/src/channels/shared/conversation/root-context.mjs +101 -0
- package/src/channels/shared/conversation/route.mjs +71 -0
- package/src/channels/shared/conversation/semantic-message.mjs +75 -0
- package/src/channels/shared/conversation/trigger-policy.mjs +80 -0
- package/src/channels/shared/conversation-state-store.mjs +1 -1
- package/src/channels/shared/harness-approval.mjs +19 -0
- package/src/channels/shared/i18n-en/shared-a.mjs +2 -0
- package/src/channels/shared/injected-context.mjs +64 -2
- package/src/channels/shared/speaker-label.mjs +46 -0
- package/src/channels/shared/text-harness-bridge.mjs +348 -32
- package/src/channels/wecom/wecom-cards.mjs +1 -1
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
runPresetCommand,
|
|
35
35
|
} from './preset-command.mjs';
|
|
36
36
|
import { askInWorkspaceSession } from './workspace-session.mjs';
|
|
37
|
-
import { HarnessApprovalQueue } from './harness-approval.mjs';
|
|
37
|
+
import { HarnessApprovalQueue, harnessApprovalDecision } from './harness-approval.mjs';
|
|
38
38
|
import {
|
|
39
39
|
BatchInputManager,
|
|
40
40
|
batchInputBusyMessage,
|
|
@@ -56,6 +56,7 @@ import {
|
|
|
56
56
|
validHarnessQuestion,
|
|
57
57
|
} from './harness-question.mjs';
|
|
58
58
|
import { deliverOutboundArtifacts } from './semantic/artifact-delivery.mjs';
|
|
59
|
+
import { prependSpeakerLabel } from './speaker-label.mjs';
|
|
59
60
|
import {
|
|
60
61
|
hasReplyReference,
|
|
61
62
|
promptContentForInboundMessage,
|
|
@@ -85,6 +86,8 @@ const FILE_ONLY_COMPLETION_TEXT = '任务已完成。';
|
|
|
85
86
|
* attempts the mark stays and the message is treated as handled.
|
|
86
87
|
*/
|
|
87
88
|
const MAX_DELIVERY_ATTEMPTS = 3;
|
|
89
|
+
/** How many plain-text messages one coalescing window folds into one dispatch. */
|
|
90
|
+
const MAX_COALESCE_MEMBERS = 8;
|
|
88
91
|
|
|
89
92
|
function cleanText(value) {
|
|
90
93
|
return typeof value === 'string' ? value.trim() : '';
|
|
@@ -190,10 +193,18 @@ export class TextHarnessBridge {
|
|
|
190
193
|
#approvals;
|
|
191
194
|
#batches = new BatchInputManager();
|
|
192
195
|
#interactionCard;
|
|
196
|
+
#contextHooks = null;
|
|
193
197
|
// Delivery attempts per message id. A failed turn releases its `markSeen` so
|
|
194
198
|
// the next poll can retry, and this counter is what keeps that from becoming
|
|
195
199
|
// an infinite loop on a mail that always fails.
|
|
196
200
|
#deliveryAttempts = new Map();
|
|
201
|
+
// Who opened the in-flight turn per conversation key, for the same-sender
|
|
202
|
+
// auto-steer lane; and the open coalescing windows awaiting their flush.
|
|
203
|
+
#turnAuthors = new Map();
|
|
204
|
+
#coalescing = new Map();
|
|
205
|
+
#autoSteerSameSender = false;
|
|
206
|
+
#autoSteerLimit = 0;
|
|
207
|
+
#coalesceWindowMs = 0;
|
|
197
208
|
|
|
198
209
|
constructor({
|
|
199
210
|
descriptor,
|
|
@@ -209,6 +220,10 @@ export class TextHarnessBridge {
|
|
|
209
220
|
signal,
|
|
210
221
|
keepaliveIntervalMs = 4_000,
|
|
211
222
|
interactionCard = null,
|
|
223
|
+
contextHooks = null,
|
|
224
|
+
autoSteerSameSender = false,
|
|
225
|
+
autoSteerLimit = 0,
|
|
226
|
+
coalesceWindowMs = 0,
|
|
212
227
|
}) {
|
|
213
228
|
if (!descriptor?.key || !descriptor?.label) throw new TypeError('A channel descriptor is required');
|
|
214
229
|
if (!bot || typeof bot.sendText !== 'function') throw new TypeError('A bot client is required');
|
|
@@ -226,6 +241,23 @@ export class TextHarnessBridge {
|
|
|
226
241
|
this.#signal = signal;
|
|
227
242
|
this.#keepaliveIntervalMs = keepaliveIntervalMs;
|
|
228
243
|
this.#interactionCard = interactionCard ?? null;
|
|
244
|
+
// Opt-in follow-up lanes for channels whose groups run on rapid consecutive
|
|
245
|
+
// posts: fold a same sender's burst into one dispatch, and steer that
|
|
246
|
+
// sender's own mid-turn follow-ups into the in-flight turn instead of
|
|
247
|
+
// queueing them behind the answer they are meant to correct. Off unless a
|
|
248
|
+
// channel asks for them, so every other channel keeps the plain queue.
|
|
249
|
+
this.#autoSteerSameSender = autoSteerSameSender === true;
|
|
250
|
+
this.#autoSteerLimit = Number.isSafeInteger(autoSteerLimit) && autoSteerLimit > 0
|
|
251
|
+
? Math.min(autoSteerLimit, 20)
|
|
252
|
+
: 0;
|
|
253
|
+
this.#coalesceWindowMs = Number.isSafeInteger(coalesceWindowMs) && coalesceWindowMs > 0
|
|
254
|
+
? Math.min(coalesceWindowMs, 3_000)
|
|
255
|
+
: 0;
|
|
256
|
+
// Channel-supplied conversation context pipeline (collect/render/consume/deliver).
|
|
257
|
+
// Channels own what context means and how it is framed; the bridge only runs
|
|
258
|
+
// the steps at the right points of one turn, so every hook is optional and a
|
|
259
|
+
// channel without one pays no context work at all.
|
|
260
|
+
this.#contextHooks = contextHooks && typeof contextHooks === 'object' ? contextHooks : null;
|
|
229
261
|
this.#deferred = createDeferredDeliveryCoordinator({ harness, state, signal, logger,
|
|
230
262
|
deliver: (entry, outcome) => this.#deliverDeferredOutcome(entry, outcome),
|
|
231
263
|
});
|
|
@@ -410,6 +442,31 @@ export class TextHarnessBridge {
|
|
|
410
442
|
this.#commandTasks.add(task);
|
|
411
443
|
return task;
|
|
412
444
|
}
|
|
445
|
+
// An addressed plain-text request that is no approve/reject token while only
|
|
446
|
+
// an approval waits is the sender moving on, not a decision: reject the
|
|
447
|
+
// waiting tool call and run this message as its own turn, so the room is not
|
|
448
|
+
// held behind a decision that is not coming. A pending question keeps its
|
|
449
|
+
// ordinary flow — the asking actor's @ text answers it, and other senders
|
|
450
|
+
// queue behind the turn (questions belong to the asking actor). Direct chats
|
|
451
|
+
// keep the plain decide-by-token path; their exits are the commands.
|
|
452
|
+
const addressedTrigger = normalized.kind === 'group' && normalized.addressed === true
|
|
453
|
+
&& normalized.plainText !== false
|
|
454
|
+
&& !hasInboundImages(normalized) && !hasInboundFiles(normalized)
|
|
455
|
+
&& Boolean(text);
|
|
456
|
+
if (addressedTrigger && !pending
|
|
457
|
+
&& this.#approvals.hasPending(key) && harnessApprovalDecision(text) === null) {
|
|
458
|
+
let task;
|
|
459
|
+
task = this.#retireWaitingInteractions(key, pending)
|
|
460
|
+
.then(() => this.#bot.sendText(normalized.replyTarget, t('已不再等待原有的待回答或待审批,已将其取消,先回应你的新请求。')))
|
|
461
|
+
.catch(() => undefined)
|
|
462
|
+
.then(() => this.#dispatchTrigger(normalized, messageId, senderId, key))
|
|
463
|
+
.finally(() => {
|
|
464
|
+
this.#acceptedMessageIds.delete(messageId);
|
|
465
|
+
this.#commandTasks.delete(task);
|
|
466
|
+
});
|
|
467
|
+
this.#commandTasks.add(task);
|
|
468
|
+
return task;
|
|
469
|
+
}
|
|
413
470
|
const approval = this.#approvals.claimReply({
|
|
414
471
|
key,
|
|
415
472
|
actor: senderId,
|
|
@@ -474,35 +531,187 @@ export class TextHarnessBridge {
|
|
|
474
531
|
pending.queue = current;
|
|
475
532
|
return current;
|
|
476
533
|
}
|
|
477
|
-
return this.#
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
534
|
+
return this.#dispatchTrigger(normalized, messageId, senderId, key, {
|
|
535
|
+
// Ordinary destinations of the lanes fall through to the busy-mode dispatcher,
|
|
536
|
+
// keeping its steering, receipts, and routing serialization for what the lanes decline.
|
|
537
|
+
exit: (exitOptions) => this.#busyMessages.dispatch({
|
|
538
|
+
key, messageId, text: normalized.content,
|
|
539
|
+
commandText: text, mode: busyMessageMode,
|
|
540
|
+
onFailure: (error) => this.#signal?.aborted || error?.code === 'turn-stopped'
|
|
541
|
+
? normalized.statusReaction?.clear() : normalized.statusReaction?.error(),
|
|
542
|
+
eligible: () => normalized.plainText !== false
|
|
543
|
+
&& !hasInboundImages(normalized) && !hasInboundFiles(normalized)
|
|
544
|
+
&& !hasReplyReference(normalized)
|
|
545
|
+
&& canAutomaticallySteer(this.#accessPolicy, normalized.kind,
|
|
546
|
+
[senderId, normalized.senderAlternateId].filter(Boolean)),
|
|
547
|
+
harness: this.#harness, state: this.#state, status: this.#status,
|
|
548
|
+
signal: this.#signal, control: { owner: this, key }, logger: this.#logger,
|
|
549
|
+
acceptedMessageIds: this.#acceptedMessageIds,
|
|
550
|
+
pendingInteraction: () => this.#pendingInteractions.has(key) || this.#approvals.hasPending(key),
|
|
551
|
+
enhancement: {
|
|
552
|
+
snapshot: this.#acceptedMessageIds.get(messageId),
|
|
553
|
+
source: () => {
|
|
554
|
+
const source = normalized.contextSource?.();
|
|
555
|
+
return { channel: this.#descriptor.key, senderId, senderName: source?.senderName,
|
|
556
|
+
conversationTitle: source?.conversationTitle,
|
|
557
|
+
chatId: source?.chatId ?? normalized.conversationId, threadId: source?.threadId };
|
|
558
|
+
},
|
|
498
559
|
},
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
560
|
+
isQueued: () => this.#queues.has(key),
|
|
561
|
+
enqueue: (enqueueOptions) => this.#enqueueMessage(normalized, messageId, senderId, key, enqueueOptions),
|
|
562
|
+
send: (reply) => this.#bot.sendText(normalized.replyTarget, reply),
|
|
563
|
+
}),
|
|
503
564
|
});
|
|
504
565
|
}
|
|
505
566
|
|
|
567
|
+
/**
|
|
568
|
+
* The ordinary trigger exit, where a message becomes a turn. Three lanes:
|
|
569
|
+
* while the conversation runs, a plain-text follow-up from the very sender
|
|
570
|
+
* who opened the in-flight turn steers that turn (the room hears the
|
|
571
|
+
* correction at the next model step instead of after the whole answer);
|
|
572
|
+
* on an idle conversation, consecutive plain-text posts from one sender fold
|
|
573
|
+
* into a single dispatch inside a short window, so a burst split across
|
|
574
|
+
* bubbles becomes one turn; everything else — other senders, media, composed
|
|
575
|
+
* payloads — keeps the per-conversation queue that preserves one voice at a
|
|
576
|
+
* time.
|
|
577
|
+
* An `exit` hook, when provided, receives every ordinary queue destination of
|
|
578
|
+
* the lanes, so a channel can fall through to richer routing (busy-mode steering
|
|
579
|
+
* and queue receipts) for what these lanes decline.
|
|
580
|
+
*/
|
|
581
|
+
#dispatchTrigger(message, messageId, senderId, key, { exit } = {}) {
|
|
582
|
+
const toQueue = (options) => (exit
|
|
583
|
+
? exit(options)
|
|
584
|
+
: this.#enqueueMessage(message, messageId, senderId, key, options));
|
|
585
|
+
const plainText = !hasInboundImages(message) && !hasInboundFiles(message)
|
|
586
|
+
&& message.plainText !== false
|
|
587
|
+
&& Boolean(controlTextOf(message));
|
|
588
|
+
const author = this.#turnAuthors.get(key);
|
|
589
|
+
if (author !== undefined && plainText
|
|
590
|
+
&& this.#autoSteerSameSender && this.#autoSteerLimit > 0
|
|
591
|
+
&& author.senderId === senderId && author.steered < this.#autoSteerLimit) {
|
|
592
|
+
const text = this.#steerTextFor(message, messageId, senderId);
|
|
593
|
+
const sessionId = this.#state.sessionFor?.(key) ?? null;
|
|
594
|
+
if (text && sessionId !== null && typeof this.#harness.steerActiveTurn === 'function') {
|
|
595
|
+
return this.#harness
|
|
596
|
+
.steerActiveTurn(sessionId, text, { owner: this, key }, this.#signal ? { signal: this.#signal } : undefined)
|
|
597
|
+
.then((steered) => (steered
|
|
598
|
+
? this.#settleSteeredTurn(author, messageId, key)
|
|
599
|
+
: toQueue()))
|
|
600
|
+
.catch(() => toQueue());
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
if (!this.#queues.has(key) && !this.#pendingInteractions.has(key) && !this.#approvals.hasPending(key)) {
|
|
604
|
+
const open = this.#coalescing.get(key);
|
|
605
|
+
if (open !== undefined) {
|
|
606
|
+
if (open.senderId === senderId && plainText && open.members.length < MAX_COALESCE_MEMBERS) {
|
|
607
|
+
open.members.push({ message, messageId });
|
|
608
|
+
return Promise.resolve();
|
|
609
|
+
}
|
|
610
|
+
// A different sender, or a payload the window may not fold: this
|
|
611
|
+
// message dispatches on its own while the window keeps collecting.
|
|
612
|
+
return toQueue();
|
|
613
|
+
}
|
|
614
|
+
if (this.#coalesceWindowMs > 0 && plainText) {
|
|
615
|
+
const opened = { senderId, members: [{ message, messageId }] };
|
|
616
|
+
const timer = setTimeout(() => { void this.#flushCoalesced(key); }, this.#coalesceWindowMs);
|
|
617
|
+
timer.unref?.();
|
|
618
|
+
this.#coalescing.set(key, opened);
|
|
619
|
+
return Promise.resolve();
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
return toQueue();
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Tag a turn's own delivery target with its conversation key so the channel can
|
|
627
|
+
* retire the resulting bubble from that conversation's ambient window: the text
|
|
628
|
+
* is in the Session transcript already. Local notices, command output, and
|
|
629
|
+
* interaction prompts send on unstamped targets and stay readable as room facts.
|
|
630
|
+
*/
|
|
631
|
+
#turnDeliveryTarget(target, conversationKey) {
|
|
632
|
+
if (!target || typeof target !== 'object'
|
|
633
|
+
|| typeof conversationKey !== 'string' || !conversationKey) return target;
|
|
634
|
+
return { ...target, dshTurnKey: conversationKey };
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** The model-facing body of an auto-steered follow-up, labelled like a turn. */
|
|
638
|
+
#steerTextFor(message, messageId, senderId) {
|
|
639
|
+
const body = cleanText(message.content) || controlTextOf(message);
|
|
640
|
+
if (!body) return null;
|
|
641
|
+
const labelled = message.kind === 'group' ? prependSpeakerLabel(body, message.speakerName) : body;
|
|
642
|
+
const snapshot = this.#acceptedMessageIds.get(messageId);
|
|
643
|
+
if (!snapshot) return labelled;
|
|
644
|
+
// A mid-turn follow-up carries the same provenance as the message that
|
|
645
|
+
// opened the turn, so the member who steers is identified to the model too.
|
|
646
|
+
const contextSource = message.contextSource?.();
|
|
647
|
+
return enhanceContextContent(labelled, snapshot, () => ({
|
|
648
|
+
channel: this.#descriptor.key,
|
|
649
|
+
senderId,
|
|
650
|
+
senderName: contextSource?.senderName,
|
|
651
|
+
conversationTitle: contextSource?.conversationTitle,
|
|
652
|
+
chatId: contextSource?.chatId ?? message.conversationId,
|
|
653
|
+
threadId: contextSource?.threadId,
|
|
654
|
+
}));
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** A steered message became part of the in-flight turn: settle its receipt. */
|
|
658
|
+
async #settleSteeredTurn(author, messageId, key) {
|
|
659
|
+
author.steered += 1;
|
|
660
|
+
if (!this.#state.hasSeen(messageId)) await this.#state.markSeen(messageId);
|
|
661
|
+
this.#status.messagesReceived += 1;
|
|
662
|
+
this.#status.lastMessageAt = new Date().toISOString();
|
|
663
|
+
this.#acceptedMessageIds.delete(messageId);
|
|
664
|
+
// The steered body now lives in the in-flight turn, just like a folded
|
|
665
|
+
// coalescer member: the channel retires its bubble from the ambient window
|
|
666
|
+
// with the turn instead of re-serving text this session has taken in.
|
|
667
|
+
if (this.#contextHooks?.onSteered) {
|
|
668
|
+
void Promise.resolve(this.#contextHooks.onSteered({ conversationKey: key, messageId }))
|
|
669
|
+
.catch((error) => {
|
|
670
|
+
this.#logger.warn?.(
|
|
671
|
+
`[dsh-im:${this.#descriptor.key}] could not retire a steered follow-up from the ambient window:`,
|
|
672
|
+
error?.message ?? error,
|
|
673
|
+
);
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
return true;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Dispatch a folded window as one turn: the merged body carries every
|
|
681
|
+
* member's text, the answer targets the newest message, and the extra
|
|
682
|
+
* members travel as `coalescedEventIds` so the channel ledger can retire
|
|
683
|
+
* them from its ambient window before it re-serves what this turn already
|
|
684
|
+
* says.
|
|
685
|
+
*/
|
|
686
|
+
async #flushCoalesced(key) {
|
|
687
|
+
const open = this.#coalescing.get(key);
|
|
688
|
+
if (open === undefined) return;
|
|
689
|
+
this.#coalescing.delete(key);
|
|
690
|
+
if (this.#signal?.aborted) {
|
|
691
|
+
for (const member of open.members) this.#acceptedMessageIds.delete(member.messageId);
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
const bodies = [];
|
|
695
|
+
for (const member of open.members) {
|
|
696
|
+
const body = cleanText(member.message.content) || controlTextOf(member.message);
|
|
697
|
+
if (body && !bodies.includes(body)) bodies.push(body);
|
|
698
|
+
this.#acceptedMessageIds.delete(member.messageId);
|
|
699
|
+
if (!this.#state.hasSeen(member.messageId)) await this.#state.markSeen(member.messageId);
|
|
700
|
+
this.#status.messagesReceived += 1;
|
|
701
|
+
}
|
|
702
|
+
if (bodies.length === 0) return;
|
|
703
|
+
this.#status.lastMessageAt = new Date().toISOString();
|
|
704
|
+
const last = open.members[open.members.length - 1];
|
|
705
|
+
const merged = {
|
|
706
|
+
...last.message,
|
|
707
|
+
content: bodies.join('\n\n'),
|
|
708
|
+
...(open.members.length > 1
|
|
709
|
+
? { coalescedEventIds: open.members.slice(0, -1).map((member) => member.messageId) }
|
|
710
|
+
: {}),
|
|
711
|
+
};
|
|
712
|
+
return this.#enqueueMessage(merged, last.messageId, open.senderId, key, { alreadyRecorded: true });
|
|
713
|
+
}
|
|
714
|
+
|
|
506
715
|
#finishLocalMessage(message, messageId, reply, { recordReceived = true } = {}) {
|
|
507
716
|
let task;
|
|
508
717
|
task = (async () => {
|
|
@@ -572,6 +781,13 @@ export class TextHarnessBridge {
|
|
|
572
781
|
)),
|
|
573
782
|
...this.#approvalTasks,
|
|
574
783
|
...this.#commandTasks,
|
|
784
|
+
// An unexpired coalescing window drains through its own timer: wait out
|
|
785
|
+
// its worst-case life so a shutdown never drops folded messages before
|
|
786
|
+
// the flush enqueues them for the queue wait below to cover.
|
|
787
|
+
...this.#coalescing.values().map(() => new Promise((resolve) => {
|
|
788
|
+
const timer = setTimeout(resolve, this.#coalesceWindowMs + 25);
|
|
789
|
+
timer.unref?.();
|
|
790
|
+
})),
|
|
575
791
|
]);
|
|
576
792
|
await this.#deferred.whenIdle();
|
|
577
793
|
}
|
|
@@ -759,6 +975,14 @@ export class TextHarnessBridge {
|
|
|
759
975
|
}
|
|
760
976
|
if (!hasImages && !hasFiles && command === '/new') {
|
|
761
977
|
await this.#state.clearSession(conversationKey);
|
|
978
|
+
if (this.#contextHooks?.onSessionCleared) {
|
|
979
|
+
try {
|
|
980
|
+
await this.#contextHooks.onSessionCleared(conversationKey);
|
|
981
|
+
} catch (error) {
|
|
982
|
+
// The cleared session is already done; a failed context reset must not block the command.
|
|
983
|
+
this.#logger.warn?.(`[dsh-im:${this.#descriptor.key}] failed to reset context anchors after /new:`, error?.message ?? error);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
762
986
|
await this.#bot.sendText(target, t('已开启新会话。请发送你的问题。'));
|
|
763
987
|
return;
|
|
764
988
|
}
|
|
@@ -782,7 +1006,7 @@ export class TextHarnessBridge {
|
|
|
782
1006
|
let streamFinished = false;
|
|
783
1007
|
if (this.#thinkingTraces && typeof this.#bot.openThinkingStream === 'function') {
|
|
784
1008
|
try {
|
|
785
|
-
stream = await this.#bot.openThinkingStream(target);
|
|
1009
|
+
stream = await this.#bot.openThinkingStream(this.#turnDeliveryTarget(target, conversationKey));
|
|
786
1010
|
thinkingStream = true;
|
|
787
1011
|
} catch (error) {
|
|
788
1012
|
stream = null;
|
|
@@ -795,7 +1019,7 @@ export class TextHarnessBridge {
|
|
|
795
1019
|
if (!stream) {
|
|
796
1020
|
if (typeof this.#bot.openDeliveryStream === 'function') {
|
|
797
1021
|
try {
|
|
798
|
-
stream = await this.#bot.openDeliveryStream(target);
|
|
1022
|
+
stream = await this.#bot.openDeliveryStream(this.#turnDeliveryTarget(target, conversationKey));
|
|
799
1023
|
semanticStream = true;
|
|
800
1024
|
} catch (error) {
|
|
801
1025
|
this.#logger.warn?.(
|
|
@@ -805,7 +1029,7 @@ export class TextHarnessBridge {
|
|
|
805
1029
|
}
|
|
806
1030
|
} else if (typeof this.#bot.openStream === 'function') {
|
|
807
1031
|
try {
|
|
808
|
-
stream = await this.#bot.openStream(target);
|
|
1032
|
+
stream = await this.#bot.openStream(this.#turnDeliveryTarget(target, conversationKey));
|
|
809
1033
|
} catch (error) {
|
|
810
1034
|
this.#logger.warn?.(
|
|
811
1035
|
`[dsh-im:${this.#descriptor.key}] unable to start a streamed reply; using text:`,
|
|
@@ -825,6 +1049,11 @@ export class TextHarnessBridge {
|
|
|
825
1049
|
let content = hasImages || hasReply
|
|
826
1050
|
? await promptContentForInboundMessage(message, { signal: this.#signal, deferImages: true })
|
|
827
1051
|
: cleanText(message.content) || undefined;
|
|
1052
|
+
// A room conversation is shared by everyone who addresses the bot in it, so
|
|
1053
|
+
// the delivered prompt says whose turn this is. Only the model-facing content
|
|
1054
|
+
// is labelled; the parsed control text, commands, and ledger keys keep the
|
|
1055
|
+
// unlabelled body they were read from.
|
|
1056
|
+
if (message.kind === 'group') content = prependSpeakerLabel(content, message.speakerName);
|
|
828
1057
|
const snapshot = this.#acceptedMessageIds.get(messageId);
|
|
829
1058
|
let contextEnhanced = false;
|
|
830
1059
|
if (snapshot) {
|
|
@@ -840,6 +1069,28 @@ export class TextHarnessBridge {
|
|
|
840
1069
|
}));
|
|
841
1070
|
contextEnhanced = content !== originalContent;
|
|
842
1071
|
}
|
|
1072
|
+
// Collected after command handling so control commands never pay for a
|
|
1073
|
+
// context lookup, and only for a plain-text prompt: a composed or media
|
|
1074
|
+
// payload already carries its own structure and stays untouched.
|
|
1075
|
+
let contextEntries = null;
|
|
1076
|
+
if (this.#contextHooks?.collect && this.#contextHooks.enabled?.() !== false && !batchSubmission
|
|
1077
|
+
&& (content === undefined || typeof content === 'string') && !hasImages && !hasFiles) {
|
|
1078
|
+
try {
|
|
1079
|
+
const collected = await this.#contextHooks.collect({
|
|
1080
|
+
message,
|
|
1081
|
+
conversationKey,
|
|
1082
|
+
sessionIdHint: this.#state.sessionFor?.(conversationKey) ?? null,
|
|
1083
|
+
});
|
|
1084
|
+
if (Array.isArray(collected) && collected.length > 0) {
|
|
1085
|
+
contextEntries = collected;
|
|
1086
|
+
const block = this.#contextHooks.render?.(collected) ?? '';
|
|
1087
|
+
if (block) content = `${block}\n\n${content ?? ''}`;
|
|
1088
|
+
}
|
|
1089
|
+
} catch (error) {
|
|
1090
|
+
// Context is an enhancement: a collector failure must not lose the user's own turn.
|
|
1091
|
+
this.#logger.warn?.(`[dsh-im:${this.#descriptor.key}] context collection failed; prompting without context:`, error?.message ?? error);
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
843
1094
|
// Start the keepalive only after the inbound payload is ready, so a
|
|
844
1095
|
// pre-prompt failure (image parsing, context building) cannot leave the
|
|
845
1096
|
// timer running; the outermost finally below clears it on every path.
|
|
@@ -863,7 +1114,11 @@ export class TextHarnessBridge {
|
|
|
863
1114
|
}, this.#keepaliveIntervalMs);
|
|
864
1115
|
keepaliveTimer.unref?.();
|
|
865
1116
|
}
|
|
866
|
-
|
|
1117
|
+
// From here the conversation has a turn in flight that this sender
|
|
1118
|
+
// opened: the auto-steer lane keys its authorship decision off this
|
|
1119
|
+
// record, and the finally below retires it when the turn settles.
|
|
1120
|
+
this.#turnAuthors.set(conversationKey, { senderId, steered: 0, turnMessageId: messageId });
|
|
1121
|
+
const { sessionId, answer, artifacts = [] } = await askInWorkspaceSession({
|
|
867
1122
|
deferredDelivery: () => ({ coordinator: this.#deferred, target: this.#descriptor.key === 'whatsapp' ? { jid: target.jid, selfChat: target.selfChat } : target }),
|
|
868
1123
|
harness: this.#harness,
|
|
869
1124
|
state: this.#state,
|
|
@@ -927,6 +1182,16 @@ export class TextHarnessBridge {
|
|
|
927
1182
|
if (batchSubmission) {
|
|
928
1183
|
this.#batches.complete(conversationKey, batchSubmission.token);
|
|
929
1184
|
}
|
|
1185
|
+
// The completed turn consumed its context: ambient records stop being offered
|
|
1186
|
+
// and a thread-root anchor is stamped for this session. A failed ask throws
|
|
1187
|
+
// past this point, so a failed turn re-offers its context on the next message.
|
|
1188
|
+
if (contextEntries && this.#contextHooks?.onConsumed) {
|
|
1189
|
+
try {
|
|
1190
|
+
await this.#contextHooks.onConsumed({ conversationKey, sessionId, entries: contextEntries });
|
|
1191
|
+
} catch (error) {
|
|
1192
|
+
this.#logger.warn?.(`[dsh-im:${this.#descriptor.key}] context consumption marking failed:`, error?.message ?? error);
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
930
1195
|
const fileOnlyCompletion = !cleanText(answer) && artifacts.length > 0;
|
|
931
1196
|
const visibleAnswer = fileOnlyCompletion
|
|
932
1197
|
? t(FILE_ONLY_COMPLETION_TEXT)
|
|
@@ -962,12 +1227,13 @@ export class TextHarnessBridge {
|
|
|
962
1227
|
}
|
|
963
1228
|
if (!streamFinished) {
|
|
964
1229
|
try {
|
|
1230
|
+
const answerTarget = this.#turnDeliveryTarget(target, conversationKey);
|
|
965
1231
|
const result = typeof this.#bot.sendDelivery === 'function'
|
|
966
1232
|
? await this.#bot.sendDelivery(
|
|
967
|
-
|
|
1233
|
+
answerTarget,
|
|
968
1234
|
createTextDeliveryBlock(visibleAnswer, answerFormat),
|
|
969
1235
|
)
|
|
970
|
-
: await this.#bot.sendText(
|
|
1236
|
+
: await this.#bot.sendText(answerTarget, visibleAnswer);
|
|
971
1237
|
textReceipt = createDeliveryReceipt({
|
|
972
1238
|
deliveryId: messageId,
|
|
973
1239
|
presentation: result?.presentation ?? `${this.#descriptor.key}-text`,
|
|
@@ -1010,6 +1276,22 @@ export class TextHarnessBridge {
|
|
|
1010
1276
|
}
|
|
1011
1277
|
// The turn produced an answer, so its retry budget is spent and reset.
|
|
1012
1278
|
this.#deliveryAttempts.delete(messageId);
|
|
1279
|
+
if (this.#contextHooks?.onDelivered) {
|
|
1280
|
+
const deliveredEventIds = [
|
|
1281
|
+
...new Set([
|
|
1282
|
+
...(Array.isArray(textReceipt?.providerMessageIds) ? textReceipt.providerMessageIds : []),
|
|
1283
|
+
...(Array.isArray(delivery.receipt?.providerMessageIds) ? delivery.receipt.providerMessageIds : []),
|
|
1284
|
+
]),
|
|
1285
|
+
];
|
|
1286
|
+
if (deliveredEventIds.length > 0) {
|
|
1287
|
+
try {
|
|
1288
|
+
await this.#contextHooks.onDelivered({ conversationKey, sessionId, providerMessageIds: deliveredEventIds });
|
|
1289
|
+
} catch (error) {
|
|
1290
|
+
// A missing binding only costs reply classification quality for these events; the answer itself reached the room.
|
|
1291
|
+
this.#logger.warn?.(`[dsh-im:${this.#descriptor.key}] bot event binding recording failed:`, error?.message ?? error);
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1013
1295
|
return delivery.receipt;
|
|
1014
1296
|
} catch (error) {
|
|
1015
1297
|
stopKeepalive();
|
|
@@ -1088,6 +1370,8 @@ export class TextHarnessBridge {
|
|
|
1088
1370
|
return error.deliveryReceipt;
|
|
1089
1371
|
} finally {
|
|
1090
1372
|
stopKeepalive();
|
|
1373
|
+
const inFlight = this.#turnAuthors.get(conversationKey);
|
|
1374
|
+
if (inFlight?.turnMessageId === messageId) this.#turnAuthors.delete(conversationKey);
|
|
1091
1375
|
await Promise.allSettled([
|
|
1092
1376
|
this.#cancelPendingInteraction(conversationKey),
|
|
1093
1377
|
this.#approvals.closeRoute(conversationKey),
|
|
@@ -1672,4 +1956,36 @@ export class TextHarnessBridge {
|
|
|
1672
1956
|
);
|
|
1673
1957
|
}
|
|
1674
1958
|
}
|
|
1959
|
+
|
|
1960
|
+
/**
|
|
1961
|
+
* Retire what waits on a human decision in this conversation: the pending
|
|
1962
|
+
* question loses its card and gets the same safe cancellation the recovered
|
|
1963
|
+
* path uses, and active tool approvals are rejected. The caller follows it
|
|
1964
|
+
* with the new request's dispatch, so the room hears the answer instead of
|
|
1965
|
+
* the waiting.
|
|
1966
|
+
*/
|
|
1967
|
+
async #retireWaitingInteractions(key, pending) {
|
|
1968
|
+
if (pending) {
|
|
1969
|
+
this.#clearPendingInteraction(key, pending.interactionId);
|
|
1970
|
+
pending.claimedReplyMessageId = null;
|
|
1971
|
+
if (pending.cardMessageId) {
|
|
1972
|
+
await this.#retireInteractionCard(pending.target, pending.cardMessageId);
|
|
1973
|
+
pending.cardMessageId = null;
|
|
1974
|
+
}
|
|
1975
|
+
if (pending.kind === 'question') {
|
|
1976
|
+
try {
|
|
1977
|
+
await this.#respondCancellation(
|
|
1978
|
+
pending.interaction,
|
|
1979
|
+
`The ${this.#descriptor.label} sender moved on to a new request before answering.`,
|
|
1980
|
+
);
|
|
1981
|
+
} catch (error) {
|
|
1982
|
+
this.#logger.warn?.(
|
|
1983
|
+
`[dsh-im:${this.#descriptor.key}] failed to cancel an unanswered pending question:`,
|
|
1984
|
+
error?.message ?? error,
|
|
1985
|
+
);
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
await this.#approvals.rejectRoute(key).catch(() => 0);
|
|
1990
|
+
}
|
|
1675
1991
|
}
|
|
@@ -48,7 +48,7 @@ export function wecomMenu({ workspace, workspaces = [] } = {}) {
|
|
|
48
48
|
entries: [
|
|
49
49
|
[t('切换工作区'), 'select:workspace'],
|
|
50
50
|
[t('🆕 新会话'), '/new'],
|
|
51
|
-
[t('⏹ 停止'), '/stop'], [t('📐 压缩'), '/compact'],
|
|
51
|
+
[t('⏹ 停止'), '/stop'], [t('📐 压缩'), '/compact'],
|
|
52
52
|
[t('📊 状态'), '/status'], [t('📖 帮助'), '/help'],
|
|
53
53
|
],
|
|
54
54
|
};
|