@qwen-code/channel-base 0.19.6 → 0.19.7
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/dist/AcpBridge.d.ts +12 -1
- package/dist/AcpBridge.js +132 -1
- package/dist/AcpBridge.js.map +1 -1
- package/dist/ChannelAgentBridge.d.ts +16 -0
- package/dist/ChannelBase.d.ts +35 -1
- package/dist/ChannelBase.js +420 -115
- package/dist/ChannelBase.js.map +1 -1
- package/dist/ChannelLoopStore.js +1 -2
- package/dist/ChannelLoopStore.js.map +1 -1
- package/dist/ChannelLoopTools.d.ts +24 -0
- package/dist/ChannelLoopTools.js +152 -0
- package/dist/ChannelLoopTools.js.map +1 -0
- package/dist/SessionRouter.d.ts +2 -1
- package/dist/SessionRouter.js +15 -2
- package/dist/SessionRouter.js.map +1 -1
- package/dist/channel-memory-intent.d.ts +11 -0
- package/dist/channel-memory-intent.js +58 -0
- package/dist/channel-memory-intent.js.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +10 -0
- package/package.json +1 -1
package/dist/ChannelBase.js
CHANGED
|
@@ -6,8 +6,9 @@ import { SenderGate } from './SenderGate.js';
|
|
|
6
6
|
import { PairingStore } from './PairingStore.js';
|
|
7
7
|
import { SessionRouter } from './SessionRouter.js';
|
|
8
8
|
import { getGlobalQwenDir } from './paths.js';
|
|
9
|
-
import { sanitizeSenderName, sanitizeQuotedText, sanitizePromptText, sanitizePromptPath, sanitizeLogText, } from './sanitize.js';
|
|
9
|
+
import { sanitizeSenderName, sanitizeQuotedText, sanitizePromptText, sanitizePromptPath, sanitizeLogText, PROMPT_UNSAFE_INVISIBLES, } from './sanitize.js';
|
|
10
10
|
import { ChannelLoopSkippedError } from './ChannelLoopScheduler.js';
|
|
11
|
+
import { parseChannelMemoryIntent, } from './channel-memory-intent.js';
|
|
11
12
|
/**
|
|
12
13
|
* Max time /clear waits for a cancelled in-flight turn to wind down before
|
|
13
14
|
* purging anyway. A wedged ACP child (stuck tool call, not reading stdin, or
|
|
@@ -22,6 +23,8 @@ const CURRENT_MESSAGE_MARKER = '[Current message - respond to this]';
|
|
|
22
23
|
const GROUP_HISTORY_ENTRY_TEXT_LIMIT = 1000;
|
|
23
24
|
const GROUP_HISTORY_ENTRY_METADATA_LIMIT = 256;
|
|
24
25
|
const LOOP_CANCEL_GRACE_MS = 5000;
|
|
26
|
+
const CHANNEL_MEMORY_CLASSIFIER_MIN_CONFIDENCE = 0.7;
|
|
27
|
+
const CHANNEL_MEMORY_CLASSIFIER_TRIGGER_RE = /(记住|记得|记一下|记忆|忘掉|忘记|清空|清除|删除|保存|remember|memory|forget)/iu;
|
|
25
28
|
/** Sentinel message for the loop-prompt timeout rejection; matched by identity below. */
|
|
26
29
|
const LOOP_TIMED_OUT_MESSAGE = 'loop timed out';
|
|
27
30
|
/**
|
|
@@ -59,6 +62,7 @@ export class ChannelBase {
|
|
|
59
62
|
/** Resolved proxy URL, available to subclasses for adapter-specific clients. */
|
|
60
63
|
proxy;
|
|
61
64
|
channelMemory;
|
|
65
|
+
memoryIntentClassifier;
|
|
62
66
|
groupHistory;
|
|
63
67
|
loopController;
|
|
64
68
|
instructedSessions = new Set();
|
|
@@ -72,16 +76,24 @@ export class ChannelBase {
|
|
|
72
76
|
* so a cleared session can't be resurrected by an already-queued prompt.
|
|
73
77
|
*/
|
|
74
78
|
sessionGenerations = new Map();
|
|
79
|
+
pendingClears = new Map();
|
|
75
80
|
/** Per-session active prompt tracking for dispatch modes. */
|
|
76
81
|
activePrompts = new Map();
|
|
77
82
|
/** Per-session message buffer for collect mode. */
|
|
78
83
|
collectBuffers = new Map();
|
|
84
|
+
preflightedEnvelopes = new WeakSet();
|
|
79
85
|
bridgeToolCallListener = (event) => {
|
|
80
86
|
this.dispatchToolCall(event);
|
|
81
87
|
};
|
|
82
88
|
bridgeSessionDiedListener = (event) => {
|
|
83
89
|
this.onSessionDied(event.sessionId);
|
|
84
90
|
};
|
|
91
|
+
channelLoopToolHandler = {
|
|
92
|
+
canHandle: (sessionId) => this.router.getTarget(sessionId)?.channelName === this.name,
|
|
93
|
+
create: (sessionId, input) => this.createLoopFromTool(sessionId, input),
|
|
94
|
+
list: (sessionId) => this.listLoopsFromTool(sessionId),
|
|
95
|
+
cancel: (sessionId, id) => this.cancelLoopFromTool(sessionId, id),
|
|
96
|
+
};
|
|
85
97
|
dispatchToolCall(event) {
|
|
86
98
|
const target = this.router.getTarget(event.sessionId);
|
|
87
99
|
const active = this.activePrompts.get(event.sessionId);
|
|
@@ -115,6 +127,7 @@ export class ChannelBase {
|
|
|
115
127
|
this.identity = Object.freeze(this.resolveIdentity(name, config));
|
|
116
128
|
this.memoryScope = Object.freeze(this.resolveMemoryScope(name, config));
|
|
117
129
|
this.channelMemory = options?.channelMemory;
|
|
130
|
+
this.memoryIntentClassifier = options?.memoryIntentClassifier;
|
|
118
131
|
this.groupHistory = new GroupHistoryStore(options?.groupHistoryPath ??
|
|
119
132
|
join(getGlobalQwenDir(), 'channels', `${encodeURIComponent(name)}-group-history.jsonl`));
|
|
120
133
|
this.loopController = options?.loopController;
|
|
@@ -125,6 +138,9 @@ export class ChannelBase {
|
|
|
125
138
|
options?.router ||
|
|
126
139
|
new SessionRouter(bridge, config.cwd, config.sessionScope);
|
|
127
140
|
this.registerSharedCommands();
|
|
141
|
+
if (this.loopController) {
|
|
142
|
+
bridge.registerChannelLoopToolHandler?.(this.channelLoopToolHandler);
|
|
143
|
+
}
|
|
128
144
|
// When running standalone, register bridge listeners directly.
|
|
129
145
|
// In gateway mode, the ChannelManager dispatches events instead.
|
|
130
146
|
this.registerBridgeEvents =
|
|
@@ -247,6 +263,9 @@ export class ChannelBase {
|
|
|
247
263
|
}
|
|
248
264
|
this.router.setBridge(bridge);
|
|
249
265
|
this.bridge = bridge;
|
|
266
|
+
if (this.loopController) {
|
|
267
|
+
bridge.registerChannelLoopToolHandler?.(this.channelLoopToolHandler);
|
|
268
|
+
}
|
|
250
269
|
if (this.registerBridgeEvents) {
|
|
251
270
|
this.attachBridgeEvents(bridge);
|
|
252
271
|
}
|
|
@@ -269,7 +288,7 @@ export class ChannelBase {
|
|
|
269
288
|
await this.loopController?.disable(job.id);
|
|
270
289
|
throw new Error(`Loop ${job.id} target is no longer authorized.`);
|
|
271
290
|
}
|
|
272
|
-
const sessionId = await this.router.resolve(this.name, job.target.senderId, job.target.chatId, job.target.threadId, job.cwd);
|
|
291
|
+
const sessionId = await this.router.resolve(this.name, job.target.senderId, job.target.chatId, job.target.threadId, job.cwd, job.target.isGroup);
|
|
273
292
|
const label = sanitizeQuotedText(job.label || job.id, 80);
|
|
274
293
|
const createdBy = sanitizeSenderName(job.createdBy || 'unknown');
|
|
275
294
|
// Without the delivery-contract sentence the model treats "post X" prompts
|
|
@@ -342,6 +361,8 @@ export class ChannelBase {
|
|
|
342
361
|
resolve: doneResolve,
|
|
343
362
|
chatId: job.target.chatId,
|
|
344
363
|
messageId: job.id,
|
|
364
|
+
senderId: job.target.senderId,
|
|
365
|
+
senderName: job.createdBy,
|
|
345
366
|
loopPrompt: true,
|
|
346
367
|
};
|
|
347
368
|
this.activePrompts.set(sessionId, promptState);
|
|
@@ -473,6 +494,7 @@ export class ChannelBase {
|
|
|
473
494
|
const lost = buffer.length;
|
|
474
495
|
const coalesced = buffer.map((b) => b.text).join('\n\n');
|
|
475
496
|
const lastEnvelope = buffer[buffer.length - 1].envelope;
|
|
497
|
+
this.notifyPromptBufferDrained(lastEnvelope.chatId, sessionId, buffer);
|
|
476
498
|
const syntheticEnvelope = {
|
|
477
499
|
...lastEnvelope,
|
|
478
500
|
text: coalesced,
|
|
@@ -482,7 +504,8 @@ export class ChannelBase {
|
|
|
482
504
|
imageBase64: undefined,
|
|
483
505
|
imageMimeType: undefined,
|
|
484
506
|
};
|
|
485
|
-
this.
|
|
507
|
+
this.markPreflighted(syntheticEnvelope);
|
|
508
|
+
this.processInbound(syntheticEnvelope).catch((err) => {
|
|
486
509
|
process.stderr.write(`[${this.name}] dropped ${lost} buffered message(s) after loop ${job.id} for session ${sessionId} (last sender ${lastEnvelope.senderId}): ${err instanceof Error ? err.message : String(err)}\n`);
|
|
487
510
|
});
|
|
488
511
|
}
|
|
@@ -581,11 +604,41 @@ export class ChannelBase {
|
|
|
581
604
|
}
|
|
582
605
|
active.cancelled = true;
|
|
583
606
|
this.stopActiveStreaming(active, sessionId, reason);
|
|
584
|
-
this.
|
|
607
|
+
this.dropCollectBuffer(sessionId);
|
|
585
608
|
this.emitTaskCancellation(active, sessionId, reason);
|
|
586
609
|
return true;
|
|
587
610
|
});
|
|
588
611
|
}
|
|
612
|
+
dropCollectBuffer(sessionId) {
|
|
613
|
+
const buffer = this.collectBuffers.get(sessionId);
|
|
614
|
+
if (!buffer)
|
|
615
|
+
return;
|
|
616
|
+
this.collectBuffers.delete(sessionId);
|
|
617
|
+
const chatId = buffer[0]?.envelope.chatId ?? '';
|
|
618
|
+
const messageIds = this.collectBufferMessageIds(buffer);
|
|
619
|
+
try {
|
|
620
|
+
this.onPromptBufferDropped(chatId, sessionId, messageIds);
|
|
621
|
+
}
|
|
622
|
+
catch (err) {
|
|
623
|
+
process.stderr.write(`[${this.name}] onPromptBufferDropped threw for session ${sessionId}: ${err instanceof Error ? err.message : err}\n`);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
notifyPromptBufferDrained(chatId, sessionId, buffer) {
|
|
627
|
+
const messageIds = this.collectBufferMessageIds(buffer);
|
|
628
|
+
if (messageIds.length === 0)
|
|
629
|
+
return;
|
|
630
|
+
try {
|
|
631
|
+
this.onPromptBufferDrained(chatId, sessionId, messageIds);
|
|
632
|
+
}
|
|
633
|
+
catch (err) {
|
|
634
|
+
process.stderr.write(`[${this.name}] onPromptBufferDrained threw for session ${sessionId}: ${err instanceof Error ? err.message : err}\n`);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
collectBufferMessageIds(buffer) {
|
|
638
|
+
return buffer
|
|
639
|
+
.map((entry) => entry.envelope.messageId)
|
|
640
|
+
.filter((id) => typeof id === 'string' && id.length > 0);
|
|
641
|
+
}
|
|
589
642
|
logCancelSessionFailure(sessionId, err) {
|
|
590
643
|
process.stderr.write(`[${sanitizeLogText(this.name, 64)}] cancelSession failed for session=${sanitizeLogText(sessionId, 64)}: ${this.lifecycleError(err)}\n`);
|
|
591
644
|
}
|
|
@@ -629,6 +682,9 @@ export class ChannelBase {
|
|
|
629
682
|
* Not called for buffered messages (collect mode) or gated/blocked messages.
|
|
630
683
|
*/
|
|
631
684
|
onPromptStart(_chatId, _sessionId, _messageId) { }
|
|
685
|
+
onPromptBuffered(_chatId, _sessionId, _messageId) { }
|
|
686
|
+
onPromptBufferDrained(_chatId, _sessionId, _messageIds) { }
|
|
687
|
+
onPromptBufferDropped(_chatId, _sessionId, _messageIds) { }
|
|
632
688
|
/**
|
|
633
689
|
* Called when a prompt finishes (response sent or cancelled).
|
|
634
690
|
* Override to hide the working indicator.
|
|
@@ -708,7 +764,7 @@ export class ChannelBase {
|
|
|
708
764
|
// purging, so a running prompt can't deliver a stale response into —
|
|
709
765
|
// or resurrect via collect-drain — the just-cleared session.
|
|
710
766
|
const active = this.activePrompts.get(id);
|
|
711
|
-
this.
|
|
767
|
+
this.dropCollectBuffer(id);
|
|
712
768
|
if (active) {
|
|
713
769
|
// Bounded cancel + wind-down wait; purge regardless of the result.
|
|
714
770
|
const settled = await this.cancelAndAwaitActive(active, id);
|
|
@@ -802,6 +858,37 @@ export class ChannelBase {
|
|
|
802
858
|
this.registerCommand('clear', clearHandler);
|
|
803
859
|
this.registerCommand('reset', clearHandler);
|
|
804
860
|
this.registerCommand('new', clearHandler);
|
|
861
|
+
this.registerCommand('remember-channel', async (envelope, args) => {
|
|
862
|
+
const text = args.trim();
|
|
863
|
+
if (text === '') {
|
|
864
|
+
await this.sendMessage(envelope.chatId, 'Usage: /remember-channel <text>');
|
|
865
|
+
return true;
|
|
866
|
+
}
|
|
867
|
+
await this.handleChannelMemoryIntent(envelope, {
|
|
868
|
+
kind: 'remember',
|
|
869
|
+
text,
|
|
870
|
+
});
|
|
871
|
+
return true;
|
|
872
|
+
});
|
|
873
|
+
this.registerCommand('channel-memory', async (envelope) => {
|
|
874
|
+
await this.handleChannelMemoryIntent(envelope, { kind: 'list' });
|
|
875
|
+
return true;
|
|
876
|
+
});
|
|
877
|
+
this.registerCommand('forget-channel', async (envelope, args) => {
|
|
878
|
+
if (!(await this.ensureChannelMemoryAuthorized(envelope))) {
|
|
879
|
+
return true;
|
|
880
|
+
}
|
|
881
|
+
if (envelope.isGroup) {
|
|
882
|
+
await this.sendMessage(envelope.chatId, 'Channel memory cannot be changed in group chats.');
|
|
883
|
+
return true;
|
|
884
|
+
}
|
|
885
|
+
if (args.toLowerCase() !== 'confirm') {
|
|
886
|
+
await this.sendMessage(envelope.chatId, 'This clears channel memory for this chat. Re-send with "confirm" (e.g. /forget-channel confirm) to proceed.');
|
|
887
|
+
return true;
|
|
888
|
+
}
|
|
889
|
+
await this.handleChannelMemoryIntent(envelope, { kind: 'clear_confirm' }, { skipPendingClear: true });
|
|
890
|
+
return true;
|
|
891
|
+
});
|
|
805
892
|
// Read-only: report the current (possibly group-shared) session and workspace.
|
|
806
893
|
// For a shared session, gate it to authorized senders like /clear — /who
|
|
807
894
|
// leaks the workspace basename, so non-members shouldn't see it either.
|
|
@@ -840,90 +927,6 @@ export class ChannelBase {
|
|
|
840
927
|
].join('\n'));
|
|
841
928
|
return true;
|
|
842
929
|
});
|
|
843
|
-
this.registerCommand('remember-channel', async (envelope, args) => {
|
|
844
|
-
if (!(await this.ensureChannelMemoryAuthorized(envelope))) {
|
|
845
|
-
return true;
|
|
846
|
-
}
|
|
847
|
-
if (envelope.isGroup) {
|
|
848
|
-
await this.sendMessage(envelope.chatId, 'Channel memory cannot be changed in group chats.');
|
|
849
|
-
return true;
|
|
850
|
-
}
|
|
851
|
-
if (args.trim() === '') {
|
|
852
|
-
await this.sendMessage(envelope.chatId, 'Usage: /remember-channel <text>');
|
|
853
|
-
return true;
|
|
854
|
-
}
|
|
855
|
-
const channelMemory = await this.getChannelMemory(envelope);
|
|
856
|
-
if (!channelMemory) {
|
|
857
|
-
return true;
|
|
858
|
-
}
|
|
859
|
-
try {
|
|
860
|
-
await channelMemory.appendChannelMemory(this.channelMemoryTarget(envelope), args.trim());
|
|
861
|
-
}
|
|
862
|
-
catch (error) {
|
|
863
|
-
const message = this.channelMemoryErrorMessage(error);
|
|
864
|
-
this.logChannelMemoryError('save', envelope, message);
|
|
865
|
-
await this.sendMessage(envelope.chatId, `Failed to save channel memory: ${this.channelMemoryUserErrorMessage()}`);
|
|
866
|
-
return true;
|
|
867
|
-
}
|
|
868
|
-
this.invalidateSessionContext(envelope);
|
|
869
|
-
await this.sendMessage(envelope.chatId, 'Channel memory updated.');
|
|
870
|
-
return true;
|
|
871
|
-
});
|
|
872
|
-
this.registerCommand('channel-memory', async (envelope) => {
|
|
873
|
-
if (!(await this.ensureChannelMemoryAuthorized(envelope))) {
|
|
874
|
-
return true;
|
|
875
|
-
}
|
|
876
|
-
if (envelope.isGroup) {
|
|
877
|
-
await this.sendMessage(envelope.chatId, 'Channel memory cannot be shown in group chats.');
|
|
878
|
-
return true;
|
|
879
|
-
}
|
|
880
|
-
const channelMemory = await this.getChannelMemory(envelope);
|
|
881
|
-
if (!channelMemory) {
|
|
882
|
-
return true;
|
|
883
|
-
}
|
|
884
|
-
let text;
|
|
885
|
-
try {
|
|
886
|
-
text = (await channelMemory.readChannelMemory(this.channelMemoryTarget(envelope))).trim();
|
|
887
|
-
}
|
|
888
|
-
catch (error) {
|
|
889
|
-
const message = this.channelMemoryErrorMessage(error);
|
|
890
|
-
this.logChannelMemoryError('read', envelope, message);
|
|
891
|
-
await this.sendMessage(envelope.chatId, `Failed to read channel memory: ${this.channelMemoryUserErrorMessage()}`);
|
|
892
|
-
return true;
|
|
893
|
-
}
|
|
894
|
-
await this.sendMessage(envelope.chatId, text === '' ? 'No channel memory saved.' : sanitizePromptText(text));
|
|
895
|
-
return true;
|
|
896
|
-
});
|
|
897
|
-
this.registerCommand('forget-channel', async (envelope, args) => {
|
|
898
|
-
if (!(await this.ensureChannelMemoryAuthorized(envelope))) {
|
|
899
|
-
return true;
|
|
900
|
-
}
|
|
901
|
-
if (envelope.isGroup) {
|
|
902
|
-
await this.sendMessage(envelope.chatId, 'Channel memory cannot be changed in group chats.');
|
|
903
|
-
return true;
|
|
904
|
-
}
|
|
905
|
-
if (args.toLowerCase() !== 'confirm') {
|
|
906
|
-
await this.sendMessage(envelope.chatId, 'This clears channel memory for this chat. Re-send with "confirm" (e.g. /forget-channel confirm) to proceed.');
|
|
907
|
-
return true;
|
|
908
|
-
}
|
|
909
|
-
const channelMemory = await this.getChannelMemory(envelope);
|
|
910
|
-
if (!channelMemory) {
|
|
911
|
-
return true;
|
|
912
|
-
}
|
|
913
|
-
let result;
|
|
914
|
-
try {
|
|
915
|
-
result = await channelMemory.clearChannelMemory(this.channelMemoryTarget(envelope));
|
|
916
|
-
}
|
|
917
|
-
catch (error) {
|
|
918
|
-
const message = this.channelMemoryErrorMessage(error);
|
|
919
|
-
this.logChannelMemoryError('clear', envelope, message);
|
|
920
|
-
await this.sendMessage(envelope.chatId, `Failed to clear channel memory: ${this.channelMemoryUserErrorMessage()}`);
|
|
921
|
-
return true;
|
|
922
|
-
}
|
|
923
|
-
this.invalidateSessionContext(envelope);
|
|
924
|
-
await this.sendMessage(envelope.chatId, result.changed ? 'Channel memory cleared.' : 'No channel memory saved.');
|
|
925
|
-
return true;
|
|
926
|
-
});
|
|
927
930
|
this.registerCommand('help', async (envelope) => {
|
|
928
931
|
const lines = [
|
|
929
932
|
'Commands:',
|
|
@@ -933,9 +936,6 @@ export class ChannelBase {
|
|
|
933
936
|
: '/clear — Clear your session (aliases: /reset, /new)',
|
|
934
937
|
'/who — Show current session & workspace',
|
|
935
938
|
'/status — Show session info',
|
|
936
|
-
'/remember-channel <text> — Save memory for this chat',
|
|
937
|
-
'/channel-memory — Show memory for this chat',
|
|
938
|
-
'/forget-channel confirm — Clear memory for this chat',
|
|
939
939
|
];
|
|
940
940
|
// Platform-specific commands (registered by adapters, not shared ones)
|
|
941
941
|
const sharedCmds = new Set([
|
|
@@ -943,11 +943,11 @@ export class ChannelBase {
|
|
|
943
943
|
'clear',
|
|
944
944
|
'reset',
|
|
945
945
|
'new',
|
|
946
|
-
'who',
|
|
947
|
-
'status',
|
|
948
946
|
'remember-channel',
|
|
949
947
|
'channel-memory',
|
|
950
948
|
'forget-channel',
|
|
949
|
+
'who',
|
|
950
|
+
'status',
|
|
951
951
|
]);
|
|
952
952
|
const platformCmds = [...this.commands.keys()].filter((c) => !sharedCmds.has(c));
|
|
953
953
|
if (platformCmds.length > 0) {
|
|
@@ -1079,6 +1079,105 @@ export class ChannelBase {
|
|
|
1079
1079
|
await this.sendMessage(envelope.chatId, `Loop ${job.id}: ${job.cron}`);
|
|
1080
1080
|
return true;
|
|
1081
1081
|
}
|
|
1082
|
+
async createLoopFromTool(sessionId, input) {
|
|
1083
|
+
if (!this.loopController) {
|
|
1084
|
+
return { text: 'Channel loops are not configured.', isError: true };
|
|
1085
|
+
}
|
|
1086
|
+
if (!this.supportsProactiveSend()) {
|
|
1087
|
+
return {
|
|
1088
|
+
text: 'This channel does not support proactive loop messages.',
|
|
1089
|
+
isError: true,
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
if (this.config.sessionScope === 'single') {
|
|
1093
|
+
return {
|
|
1094
|
+
text: 'Loops are not supported when sessionScope is single.',
|
|
1095
|
+
isError: true,
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
const target = this.loopToolTarget(sessionId);
|
|
1099
|
+
if (typeof target === 'string')
|
|
1100
|
+
return { text: target, isError: true };
|
|
1101
|
+
if (!this.supportsProactiveTarget(target)) {
|
|
1102
|
+
return {
|
|
1103
|
+
text: 'This channel does not support proactive loop messages for this chat target.',
|
|
1104
|
+
isError: true,
|
|
1105
|
+
};
|
|
1106
|
+
}
|
|
1107
|
+
const cron = input.cron.trim();
|
|
1108
|
+
try {
|
|
1109
|
+
this.loopController.validateCron(cron);
|
|
1110
|
+
}
|
|
1111
|
+
catch (err) {
|
|
1112
|
+
return {
|
|
1113
|
+
text: `Invalid cron expression: ${err instanceof Error ? err.message : String(err)}`,
|
|
1114
|
+
isError: true,
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1117
|
+
const prompt = sanitizePromptText(input.prompt.trim());
|
|
1118
|
+
if (Array.from(prompt).length > MAX_LOOP_PROMPT_CHARS) {
|
|
1119
|
+
return {
|
|
1120
|
+
text: `Loop prompt is too long; keep it under ${MAX_LOOP_PROMPT_CHARS} characters.`,
|
|
1121
|
+
isError: true,
|
|
1122
|
+
};
|
|
1123
|
+
}
|
|
1124
|
+
const loopInput = {
|
|
1125
|
+
channelName: this.name,
|
|
1126
|
+
target,
|
|
1127
|
+
cwd: this.config.cwd,
|
|
1128
|
+
cron,
|
|
1129
|
+
prompt,
|
|
1130
|
+
label: truncateLoopLabel(prompt),
|
|
1131
|
+
recurring: input.recurring !== false,
|
|
1132
|
+
createdBy: sanitizeSenderName(this.toolCallerName(sessionId, target)),
|
|
1133
|
+
};
|
|
1134
|
+
let job;
|
|
1135
|
+
if (this.loopController.createForTarget) {
|
|
1136
|
+
job = await this.loopController.createForTarget(loopInput, MAX_LOOP_JOBS_PER_TARGET);
|
|
1137
|
+
}
|
|
1138
|
+
else {
|
|
1139
|
+
const existingJobs = await this.loopController.listForTarget(this.name, target);
|
|
1140
|
+
if (existingJobs.filter((existingJob) => existingJob.enabled).length <
|
|
1141
|
+
MAX_LOOP_JOBS_PER_TARGET) {
|
|
1142
|
+
job = await this.loopController.create(loopInput);
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
if (!job) {
|
|
1146
|
+
return {
|
|
1147
|
+
text: 'Too many loops for this chat. Cancel an existing loop before adding another.',
|
|
1148
|
+
isError: true,
|
|
1149
|
+
};
|
|
1150
|
+
}
|
|
1151
|
+
return `Loop ${job.id}: ${job.cron}`;
|
|
1152
|
+
}
|
|
1153
|
+
async listLoopsFromTool(sessionId) {
|
|
1154
|
+
if (!this.loopController) {
|
|
1155
|
+
return { text: 'Channel loops are not configured.', isError: true };
|
|
1156
|
+
}
|
|
1157
|
+
const target = this.loopToolTarget(sessionId);
|
|
1158
|
+
if (typeof target === 'string')
|
|
1159
|
+
return { text: target, isError: true };
|
|
1160
|
+
const jobs = await this.loopController.listForTarget(this.name, target);
|
|
1161
|
+
if (jobs.length === 0)
|
|
1162
|
+
return 'No loops.';
|
|
1163
|
+
return jobs.map((job) => this.formatLoopListLine(job)).join('\n');
|
|
1164
|
+
}
|
|
1165
|
+
async cancelLoopFromTool(sessionId, id) {
|
|
1166
|
+
if (!this.loopController) {
|
|
1167
|
+
return { text: 'Channel loops are not configured.', isError: true };
|
|
1168
|
+
}
|
|
1169
|
+
const target = this.loopToolTarget(sessionId);
|
|
1170
|
+
if (typeof target === 'string')
|
|
1171
|
+
return { text: target, isError: true };
|
|
1172
|
+
const jobs = await this.loopController.listForTarget(this.name, target);
|
|
1173
|
+
const match = jobs.find((job) => job.id === id);
|
|
1174
|
+
if (!match)
|
|
1175
|
+
return { text: `No loop ${id}.`, isError: true };
|
|
1176
|
+
const disabled = await this.loopController.disable(id);
|
|
1177
|
+
return disabled
|
|
1178
|
+
? `Cancelled loop ${id}.`
|
|
1179
|
+
: { text: `Failed to cancel loop ${id}.`, isError: true };
|
|
1180
|
+
}
|
|
1082
1181
|
async handleLoopList(envelope) {
|
|
1083
1182
|
if (!this.loopController)
|
|
1084
1183
|
return true;
|
|
@@ -1160,36 +1259,57 @@ export class ChannelBase {
|
|
|
1160
1259
|
}
|
|
1161
1260
|
const jobs = await this.loopController.listForTarget(this.name, this.loopTargetFromEnvelope(envelope));
|
|
1162
1261
|
const match = jobs.find((job) => job.id === id);
|
|
1163
|
-
|
|
1164
|
-
|
|
1262
|
+
if (!match) {
|
|
1263
|
+
await this.sendMessage(envelope.chatId, `No loop ${id}.`);
|
|
1264
|
+
return true;
|
|
1265
|
+
}
|
|
1266
|
+
const disabled = await this.loopController.disable(id);
|
|
1267
|
+
await this.sendMessage(envelope.chatId, disabled ? `Cancelled loop ${id}.` : `Failed to cancel loop ${id}.`);
|
|
1165
1268
|
return true;
|
|
1166
1269
|
}
|
|
1167
1270
|
loopTargetFromEnvelope(envelope) {
|
|
1168
|
-
return {
|
|
1271
|
+
return this.normalizeLoopTarget({
|
|
1169
1272
|
channelName: this.name,
|
|
1170
1273
|
senderId: envelope.senderId,
|
|
1171
1274
|
chatId: envelope.chatId,
|
|
1172
1275
|
threadId: envelope.threadId,
|
|
1173
1276
|
isGroup: envelope.isGroup === true,
|
|
1174
|
-
};
|
|
1277
|
+
});
|
|
1175
1278
|
}
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1279
|
+
normalizeLoopTarget(target) {
|
|
1280
|
+
// Older persisted loop targets may not have isGroup; treat them as one-to-one chats.
|
|
1281
|
+
return { ...target, isGroup: target.isGroup === true };
|
|
1282
|
+
}
|
|
1283
|
+
loopToolTarget(sessionId) {
|
|
1284
|
+
const target = this.router.getTarget(sessionId);
|
|
1285
|
+
if (!target || target.channelName !== this.name) {
|
|
1286
|
+
return 'No channel target is bound to this session.';
|
|
1287
|
+
}
|
|
1288
|
+
if (!this.isAuthorizedForSharedSessionToolCall(target, sessionId)) {
|
|
1289
|
+
return 'Only authorized members can use loops in this shared session.';
|
|
1290
|
+
}
|
|
1291
|
+
const senderId = this.activePrompts.get(sessionId)?.senderId;
|
|
1292
|
+
const normalizedTarget = this.normalizeLoopTarget(target);
|
|
1293
|
+
if (senderId && this.isSharedSessionTarget(normalizedTarget)) {
|
|
1294
|
+
return { ...normalizedTarget, senderId };
|
|
1179
1295
|
}
|
|
1296
|
+
return normalizedTarget;
|
|
1297
|
+
}
|
|
1298
|
+
isStoredLoopTargetAuthorized(target, senderName) {
|
|
1299
|
+
const normalizedTarget = this.normalizeLoopTarget(target);
|
|
1180
1300
|
const envelope = {
|
|
1181
1301
|
channelName: this.name,
|
|
1182
|
-
senderId:
|
|
1302
|
+
senderId: normalizedTarget.senderId,
|
|
1183
1303
|
senderName,
|
|
1184
|
-
chatId:
|
|
1304
|
+
chatId: normalizedTarget.chatId,
|
|
1185
1305
|
text: '',
|
|
1186
|
-
threadId:
|
|
1187
|
-
isGroup:
|
|
1306
|
+
threadId: normalizedTarget.threadId,
|
|
1307
|
+
isGroup: normalizedTarget.isGroup,
|
|
1188
1308
|
isMentioned: true,
|
|
1189
1309
|
isReplyToBot: true,
|
|
1190
1310
|
};
|
|
1191
1311
|
return (this.groupGate.check(envelope).allowed &&
|
|
1192
|
-
this.gate.isAllowed(
|
|
1312
|
+
this.gate.isAllowed(normalizedTarget.senderId) &&
|
|
1193
1313
|
this.isAuthorizedForSharedSession(envelope));
|
|
1194
1314
|
}
|
|
1195
1315
|
/** Check if a message text matches a registered local command. */
|
|
@@ -1254,6 +1374,125 @@ export class ChannelBase {
|
|
|
1254
1374
|
}
|
|
1255
1375
|
return this.channelMemory;
|
|
1256
1376
|
}
|
|
1377
|
+
async handleChannelMemoryIntent(envelope, intent, options = {}) {
|
|
1378
|
+
if (!(await this.ensureChannelMemoryAuthorized(envelope))) {
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
if (envelope.isGroup && intent.kind !== 'list') {
|
|
1382
|
+
await this.sendMessage(envelope.chatId, 'Channel memory cannot be changed in group chats.');
|
|
1383
|
+
return;
|
|
1384
|
+
}
|
|
1385
|
+
if (intent.kind === 'clear_request') {
|
|
1386
|
+
this.setPendingClear(this.clearPendingKey(envelope));
|
|
1387
|
+
await this.sendMessage(envelope.chatId, 'This clears channel memory for this chat. Say "确认清空记忆" or "confirm clear memory" to proceed.');
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
const channelMemory = await this.getChannelMemory(envelope);
|
|
1391
|
+
if (!channelMemory) {
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
if (intent.kind === 'remember') {
|
|
1395
|
+
try {
|
|
1396
|
+
await channelMemory.appendChannelMemory(this.channelMemoryTarget(envelope), intent.text);
|
|
1397
|
+
}
|
|
1398
|
+
catch (error) {
|
|
1399
|
+
const message = this.channelMemoryErrorMessage(error);
|
|
1400
|
+
this.logChannelMemoryError('save', envelope, message);
|
|
1401
|
+
await this.sendMessage(envelope.chatId, `Failed to save channel memory: ${this.channelMemoryUserErrorMessage()}`);
|
|
1402
|
+
return;
|
|
1403
|
+
}
|
|
1404
|
+
this.invalidateSessionContext(envelope);
|
|
1405
|
+
await this.sendMessage(envelope.chatId, 'Channel memory updated.');
|
|
1406
|
+
return;
|
|
1407
|
+
}
|
|
1408
|
+
if (intent.kind === 'list') {
|
|
1409
|
+
let text;
|
|
1410
|
+
try {
|
|
1411
|
+
text = (await channelMemory.readChannelMemory(this.channelMemoryTarget(envelope))).trim();
|
|
1412
|
+
}
|
|
1413
|
+
catch (error) {
|
|
1414
|
+
const message = this.channelMemoryErrorMessage(error);
|
|
1415
|
+
this.logChannelMemoryError('read', envelope, message);
|
|
1416
|
+
await this.sendMessage(envelope.chatId, `Failed to read channel memory: ${this.channelMemoryUserErrorMessage()}`);
|
|
1417
|
+
return;
|
|
1418
|
+
}
|
|
1419
|
+
await this.sendMessage(envelope.chatId, text === '' ? 'No channel memory saved.' : sanitizePromptText(text));
|
|
1420
|
+
return;
|
|
1421
|
+
}
|
|
1422
|
+
if (intent.kind === 'clear_confirm') {
|
|
1423
|
+
if (!options.skipPendingClear) {
|
|
1424
|
+
const pendingKey = this.clearPendingKey(envelope);
|
|
1425
|
+
const expiresAt = this.pendingClears.get(pendingKey);
|
|
1426
|
+
this.pendingClears.delete(pendingKey);
|
|
1427
|
+
if (expiresAt === undefined || expiresAt < Date.now()) {
|
|
1428
|
+
await this.sendMessage(envelope.chatId, 'No pending clear request. Say "清空记忆" first.');
|
|
1429
|
+
return;
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
let result;
|
|
1433
|
+
try {
|
|
1434
|
+
result = await channelMemory.clearChannelMemory(this.channelMemoryTarget(envelope));
|
|
1435
|
+
}
|
|
1436
|
+
catch (error) {
|
|
1437
|
+
const message = this.channelMemoryErrorMessage(error);
|
|
1438
|
+
this.logChannelMemoryError('clear', envelope, message);
|
|
1439
|
+
await this.sendMessage(envelope.chatId, `Failed to clear channel memory: ${this.channelMemoryUserErrorMessage()}`);
|
|
1440
|
+
return;
|
|
1441
|
+
}
|
|
1442
|
+
this.invalidateSessionContext(envelope);
|
|
1443
|
+
await this.sendMessage(envelope.chatId, result.changed ? 'Channel memory cleared.' : 'No channel memory saved.');
|
|
1444
|
+
return;
|
|
1445
|
+
}
|
|
1446
|
+
const unhandled = intent;
|
|
1447
|
+
throw new Error(`Unhandled channel memory intent: ${JSON.stringify(unhandled)}`);
|
|
1448
|
+
}
|
|
1449
|
+
shouldClassifyChannelMemoryIntent(text) {
|
|
1450
|
+
const normalized = text.replace(PROMPT_UNSAFE_INVISIBLES, '').trim();
|
|
1451
|
+
return (this.channelMemory !== undefined &&
|
|
1452
|
+
this.memoryIntentClassifier !== undefined &&
|
|
1453
|
+
!normalized.startsWith('/') &&
|
|
1454
|
+
CHANNEL_MEMORY_CLASSIFIER_TRIGGER_RE.test(normalized));
|
|
1455
|
+
}
|
|
1456
|
+
clearPendingKey(envelope) {
|
|
1457
|
+
return `${this.name}:${envelope.chatId}:${envelope.threadId ?? ''}:${envelope.senderId ?? ''}`;
|
|
1458
|
+
}
|
|
1459
|
+
setPendingClear(key) {
|
|
1460
|
+
const now = Date.now();
|
|
1461
|
+
for (const [pendingKey, expiresAt] of this.pendingClears) {
|
|
1462
|
+
if (expiresAt < now) {
|
|
1463
|
+
this.pendingClears.delete(pendingKey);
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
this.pendingClears.set(key, now + 60_000);
|
|
1467
|
+
}
|
|
1468
|
+
async classifyChannelMemoryIntent(text) {
|
|
1469
|
+
if (!this.memoryIntentClassifier) {
|
|
1470
|
+
return null;
|
|
1471
|
+
}
|
|
1472
|
+
let classified;
|
|
1473
|
+
try {
|
|
1474
|
+
classified =
|
|
1475
|
+
await this.memoryIntentClassifier.classifyChannelMemoryIntent(text);
|
|
1476
|
+
}
|
|
1477
|
+
catch (error) {
|
|
1478
|
+
process.stderr.write(`[${this.name}] channel memory intent classifier failed: ${sanitizeLogText(this.channelMemoryErrorMessage(error), 200)}\n`);
|
|
1479
|
+
return null;
|
|
1480
|
+
}
|
|
1481
|
+
if (classified.confidence < CHANNEL_MEMORY_CLASSIFIER_MIN_CONFIDENCE) {
|
|
1482
|
+
return null;
|
|
1483
|
+
}
|
|
1484
|
+
if (classified.intent === 'remember') {
|
|
1485
|
+
const memory = classified.memory?.trim();
|
|
1486
|
+
return memory ? { kind: 'remember', text: memory } : null;
|
|
1487
|
+
}
|
|
1488
|
+
if (classified.intent === 'list') {
|
|
1489
|
+
return { kind: 'list' };
|
|
1490
|
+
}
|
|
1491
|
+
if (classified.intent === 'clear_all') {
|
|
1492
|
+
return { kind: 'clear_request' };
|
|
1493
|
+
}
|
|
1494
|
+
return null;
|
|
1495
|
+
}
|
|
1257
1496
|
channelMemoryErrorMessage(error) {
|
|
1258
1497
|
return error instanceof Error ? error.message : String(error);
|
|
1259
1498
|
}
|
|
@@ -1286,10 +1525,26 @@ export class ChannelBase {
|
|
|
1286
1525
|
* gate can't drift; each caller sends its own rejection wording.
|
|
1287
1526
|
*/
|
|
1288
1527
|
isAuthorizedForSharedSession(envelope) {
|
|
1289
|
-
|
|
1528
|
+
return this.isAuthorizedForSharedSessionTarget(envelope);
|
|
1529
|
+
}
|
|
1530
|
+
isAuthorizedForSharedSessionTarget(target) {
|
|
1531
|
+
if (!this.isSharedSessionTarget(target))
|
|
1290
1532
|
return true;
|
|
1291
1533
|
const authorized = this.config.allowedUsers;
|
|
1292
|
-
return authorized.length === 0 || authorized.includes(
|
|
1534
|
+
return authorized.length === 0 || authorized.includes(target.senderId);
|
|
1535
|
+
}
|
|
1536
|
+
isAuthorizedForSharedSessionToolCall(target, sessionId) {
|
|
1537
|
+
if (!this.isSharedSessionTarget(target))
|
|
1538
|
+
return true;
|
|
1539
|
+
const authorized = this.config.allowedUsers;
|
|
1540
|
+
if (authorized.length === 0)
|
|
1541
|
+
return true;
|
|
1542
|
+
const senderId = this.activePrompts.get(sessionId)?.senderId;
|
|
1543
|
+
return senderId !== undefined && authorized.includes(senderId);
|
|
1544
|
+
}
|
|
1545
|
+
toolCallerName(sessionId, target) {
|
|
1546
|
+
const active = this.activePrompts.get(sessionId);
|
|
1547
|
+
return active?.senderName || active?.senderId || target.senderId || 'agent';
|
|
1293
1548
|
}
|
|
1294
1549
|
stopActiveStreaming(active, sessionId, reason) {
|
|
1295
1550
|
try {
|
|
@@ -1537,21 +1792,56 @@ export class ChannelBase {
|
|
|
1537
1792
|
});
|
|
1538
1793
|
return `${GROUP_HISTORY_CONTEXT_MARKER}\n${formatted.join('\n')}\n\n${CURRENT_MESSAGE_MARKER}\n${promptText}`;
|
|
1539
1794
|
}
|
|
1540
|
-
|
|
1541
|
-
// 1. Group gate: policy + allowlist + mention gating
|
|
1795
|
+
preflightInbound(envelope) {
|
|
1542
1796
|
const groupResult = this.groupGate.check(envelope);
|
|
1543
1797
|
if (!groupResult.allowed) {
|
|
1544
1798
|
if (groupResult.reason === 'mention_required') {
|
|
1545
1799
|
this.recordPendingGroupHistory(envelope);
|
|
1546
1800
|
}
|
|
1547
|
-
return;
|
|
1801
|
+
return false;
|
|
1548
1802
|
}
|
|
1549
|
-
// 2. Sender gate: allowlist / pairing / open
|
|
1550
1803
|
const result = this.gate.check(envelope.senderId, envelope.senderName);
|
|
1551
1804
|
if (!result.allowed) {
|
|
1552
1805
|
if (result.pairingCode !== undefined) {
|
|
1553
|
-
|
|
1806
|
+
return this.onPairingRequired(envelope.chatId, result.pairingCode)
|
|
1807
|
+
.then(() => false)
|
|
1808
|
+
.catch((err) => {
|
|
1809
|
+
process.stderr.write(`[Channel:${this.name}] pairing notification failed: ${sanitizeLogText(err instanceof Error ? err.message : String(err), 200)}\n`);
|
|
1810
|
+
return false;
|
|
1811
|
+
});
|
|
1554
1812
|
}
|
|
1813
|
+
return false;
|
|
1814
|
+
}
|
|
1815
|
+
this.markPreflighted(envelope);
|
|
1816
|
+
return true;
|
|
1817
|
+
}
|
|
1818
|
+
async handleInbound(envelope) {
|
|
1819
|
+
const preflight = this.preflightInbound(envelope);
|
|
1820
|
+
if (!(isPromiseLike(preflight) ? await preflight : preflight))
|
|
1821
|
+
return;
|
|
1822
|
+
await this.processInbound(envelope);
|
|
1823
|
+
}
|
|
1824
|
+
markPreflighted(envelope) {
|
|
1825
|
+
this.preflightedEnvelopes.add(envelope);
|
|
1826
|
+
}
|
|
1827
|
+
/**
|
|
1828
|
+
* Process an inbound message after preflight gates have passed.
|
|
1829
|
+
*
|
|
1830
|
+
* This method does not run group gating, sender allowlisting, or pairing
|
|
1831
|
+
* checks. Callers must run preflightInbound() first unless the envelope was
|
|
1832
|
+
* already preflighted, such as during collect-buffer drain.
|
|
1833
|
+
*/
|
|
1834
|
+
async processInbound(envelope) {
|
|
1835
|
+
if (!this.preflightedEnvelopes.delete(envelope)) {
|
|
1836
|
+
throw new Error('processInbound called without a successful preflightInbound check.');
|
|
1837
|
+
}
|
|
1838
|
+
let memoryIntent = parseChannelMemoryIntent(envelope.text);
|
|
1839
|
+
if (!memoryIntent &&
|
|
1840
|
+
this.shouldClassifyChannelMemoryIntent(envelope.text)) {
|
|
1841
|
+
memoryIntent = await this.classifyChannelMemoryIntent(envelope.text);
|
|
1842
|
+
}
|
|
1843
|
+
if (memoryIntent) {
|
|
1844
|
+
await this.handleChannelMemoryIntent(envelope, memoryIntent);
|
|
1555
1845
|
return;
|
|
1556
1846
|
}
|
|
1557
1847
|
// 3. Slash command handling — before session/agent routing
|
|
@@ -1590,7 +1880,7 @@ export class ChannelBase {
|
|
|
1590
1880
|
return;
|
|
1591
1881
|
}
|
|
1592
1882
|
}
|
|
1593
|
-
const sessionId = await this.router.resolve(this.name, envelope.senderId, envelope.chatId, envelope.threadId, this.config.cwd);
|
|
1883
|
+
const sessionId = await this.router.resolve(this.name, envelope.senderId, envelope.chatId, envelope.threadId, this.config.cwd, envelope.isGroup);
|
|
1594
1884
|
// Bang (!) execution — a private 1:1 session has a single operator, so
|
|
1595
1885
|
// direct shell execution stays allowed. Group/shared contexts were refused
|
|
1596
1886
|
// above, before the session was resolved.
|
|
@@ -1713,6 +2003,12 @@ export class ChannelBase {
|
|
|
1713
2003
|
this.collectBuffers.set(sessionId, buffer);
|
|
1714
2004
|
}
|
|
1715
2005
|
buffer.push({ text: promptText, envelope });
|
|
2006
|
+
try {
|
|
2007
|
+
this.onPromptBuffered(envelope.chatId, sessionId, envelope.messageId);
|
|
2008
|
+
}
|
|
2009
|
+
catch (err) {
|
|
2010
|
+
process.stderr.write(`[${this.name}] onPromptBuffered threw for session ${sessionId}: ${err instanceof Error ? err.message : err}\n`);
|
|
2011
|
+
}
|
|
1716
2012
|
return;
|
|
1717
2013
|
}
|
|
1718
2014
|
case 'steer': {
|
|
@@ -1875,6 +2171,8 @@ export class ChannelBase {
|
|
|
1875
2171
|
resolve: doneResolve,
|
|
1876
2172
|
chatId: envelope.chatId,
|
|
1877
2173
|
messageId: envelope.messageId,
|
|
2174
|
+
senderId: envelope.senderId,
|
|
2175
|
+
senderName: envelope.senderName,
|
|
1878
2176
|
};
|
|
1879
2177
|
// This turn is now the single owner of the session's active-prompt slot.
|
|
1880
2178
|
// (Steer no longer hands a still-active session to a replacement; only
|
|
@@ -2042,6 +2340,7 @@ export class ChannelBase {
|
|
|
2042
2340
|
const lost = buffer.length;
|
|
2043
2341
|
const coalesced = buffer.map((b) => b.text).join('\n\n');
|
|
2044
2342
|
const lastEnvelope = buffer[buffer.length - 1].envelope;
|
|
2343
|
+
this.notifyPromptBufferDrained(lastEnvelope.chatId, sessionId, buffer);
|
|
2045
2344
|
// Re-enter handleInbound with the coalesced message
|
|
2046
2345
|
const syntheticEnvelope = {
|
|
2047
2346
|
...lastEnvelope,
|
|
@@ -2054,9 +2353,10 @@ export class ChannelBase {
|
|
|
2054
2353
|
imageBase64: undefined,
|
|
2055
2354
|
imageMimeType: undefined,
|
|
2056
2355
|
};
|
|
2356
|
+
this.markPreflighted(syntheticEnvelope);
|
|
2057
2357
|
// Queue the coalesced prompt (don't await to avoid deadlock on the queue).
|
|
2058
2358
|
// Surface a drain failure instead of silently losing buffered turns.
|
|
2059
|
-
this.
|
|
2359
|
+
this.processInbound(syntheticEnvelope).catch((err) => {
|
|
2060
2360
|
process.stderr.write(`[${this.name}] dropped ${lost} buffered message(s) on collect re-entry for session ${sessionId} (last sender ${lastEnvelope.senderId}): ${err instanceof Error ? err.message : String(err)}\n`);
|
|
2061
2361
|
});
|
|
2062
2362
|
}
|
|
@@ -2077,6 +2377,11 @@ export class ChannelBase {
|
|
|
2077
2377
|
function truncateGroupHistoryField(value) {
|
|
2078
2378
|
return value.slice(0, GROUP_HISTORY_ENTRY_METADATA_LIMIT);
|
|
2079
2379
|
}
|
|
2380
|
+
function isPromiseLike(value) {
|
|
2381
|
+
return (value !== null &&
|
|
2382
|
+
(typeof value === 'object' || typeof value === 'function') &&
|
|
2383
|
+
typeof value.then === 'function');
|
|
2384
|
+
}
|
|
2080
2385
|
function truncateLoopLabel(prompt) {
|
|
2081
2386
|
const chars = Array.from(prompt);
|
|
2082
2387
|
return chars.length > 60 ? `${chars.slice(0, 57).join('')}...` : prompt;
|