@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.
@@ -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.handleInbound(syntheticEnvelope).catch((err) => {
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.collectBuffers.delete(sessionId);
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.collectBuffers.delete(id);
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
- const disabled = match ? await this.loopController.disable(id) : false;
1164
- await this.sendMessage(envelope.chatId, disabled ? `Cancelled loop ${id}.` : `No loop ${id}.`);
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
- isStoredLoopTargetAuthorized(target, senderName) {
1177
- if (target.isGroup === undefined) {
1178
- return false;
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: target.senderId,
1302
+ senderId: normalizedTarget.senderId,
1183
1303
  senderName,
1184
- chatId: target.chatId,
1304
+ chatId: normalizedTarget.chatId,
1185
1305
  text: '',
1186
- threadId: target.threadId,
1187
- isGroup: target.isGroup === true,
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(target.senderId) &&
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
- if (!this.isSharedSession(envelope))
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(envelope.senderId);
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
- async handleInbound(envelope) {
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; // silently drop — no pairing, no reply
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
- await this.onPairingRequired(envelope.chatId, result.pairingCode);
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.handleInbound(syntheticEnvelope).catch((err) => {
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;