@@ -66,7 +66,7 @@ import { normalizeIMessageHandle } from "../targets.js";
6666import { attachIMessageMonitorAbortHandler } from "./abort-handler.js" ;
6767import { runIMessageCatchup } from "./catchup-bridge.js" ;
6868import { advanceIMessageCatchupCursor , resolveCatchupConfig } from "./catchup.js" ;
69- import { combineIMessagePayloads } from "./coalesce.js" ;
69+ import { combineIMessagePayloads , iMessageTextHasUrl , isIMessageSplitLeadIn } from "./coalesce.js" ;
7070import { repairIMessageConversationAnchor } from "./conversation-repair.js" ;
7171import { createIMessageEchoCachingSend , deliverReplies } from "./deliver.js" ;
7272import { resolveIMessageDmHistoryContext , resolveIMessageDmHistoryLimit } from "./dm-history.js" ;
@@ -346,11 +346,21 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
346346 ? await resolveIMessageStartupRowidWatermark ( watchSourceDbPath )
347347 : null ;
348348
349- // When `coalesceSameSenderDms` is enabled and the user has not set an
350- // explicit inbound debounce for this channel, widen the window to 2500 ms.
351- // Apple's split-send for `<command> <URL>` arrives ~0.8-2.0 s apart on most
352- // setups, so the legacy 0 ms default would flush the command alone before
353- // the URL row reaches the debouncer.
349+ // `coalesceSameSenderDms` merges Apple's split-send — a `<command> <URL>` or
350+ // `<caption> <image>` send that arrives as two `chat.db` rows ~0.8-2.0 s
351+ // apart — into one agent turn, WITHOUT taxing normal conversation. Rather
352+ // than debounce every DM (which delayed every reply by the full window), the
353+ // monitor classifies each DM:
354+ // - "lead-in" — a short bare fragment (`Dump`) that plausibly precedes
355+ // a payload. Held until the payload arrives or the window
356+ // elapses.
357+ // - "payload-join" — a URL/attachment that completes a pending lead-in.
358+ // Merged into the held lead-in and flushed immediately.
359+ // - "instant" — everything else (prose, questions, lone URLs). Zero
360+ // added latency.
361+ // The window only bounds how long a lead-in waits for a follow-up, so it must
362+ // still exceed Apple's max split gap; real split-sends flush as soon as the
363+ // payload row lands.
354364 const coalesceSameSenderDms = imessageCfg . coalesceSameSenderDms === true ;
355365 const inboundCfg = cfg . messages ?. inbound ;
356366 const hasExplicitInboundDebounce =
@@ -359,8 +369,59 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
359369 const debounceMsOverride =
360370 coalesceSameSenderDms && ! hasExplicitInboundDebounce ? 2500 : undefined ;
361371
372+ // Keys of DM lead-ins currently buffered, awaiting a payload follow-up. A
373+ // payload that matches a pending key merges into the held lead-in instead of
374+ // dispatching on its own.
375+ const pendingLeadInKeys = new Set < string > ( ) ;
376+
377+ const buildDmCoalesceKey = ( msg : IMessagePayload ) : string | null => {
378+ const sender = msg . sender ?. trim ( ) ;
379+ if ( ! sender ) {
380+ return null ;
381+ }
382+ const conversationId =
383+ msg . chat_id != null
384+ ? `chat:${ msg . chat_id } `
385+ : ( msg . chat_guid ?? msg . chat_identifier ?? "unknown" ) ;
386+ return `imessage:${ accountInfo . accountId } :dm:${ conversationId } :${ sender } ` ;
387+ } ;
388+
389+ type DmCoalesceMode = "lead-in" | "payload-join" | "instant" ;
390+ type DmCoalesceDecision = { mode : DmCoalesceMode ; key : string } ;
391+
392+ // Classify a DM for split-send coalescing. Returns null when coalescing does
393+ // not apply (disabled, group chat, reaction, or a from-me echo), so the
394+ // legacy/group path handles the message.
395+ const classifyDmCoalesce = ( msg : IMessagePayload ) : DmCoalesceDecision | null => {
396+ if ( ! coalesceSameSenderDms || msg . is_group === true ) {
397+ return null ;
398+ }
399+ if ( msg . is_from_me === true ) {
400+ return null ;
401+ }
402+ if ( resolveIMessageReactionContext ( msg , ( msg . text ?? "" ) . trim ( ) ) ) {
403+ return null ;
404+ }
405+ const key = buildDmCoalesceKey ( msg ) ;
406+ if ( ! key ) {
407+ return null ;
408+ }
409+ const hasMedia = Boolean (
410+ msg . attachments ?. some ( ( attachment ) => ! isIMessagePluginPayloadAttachment ( attachment ) ) ,
411+ ) ;
412+ const isPayload = hasMedia || iMessageTextHasUrl ( msg . text ) ;
413+ if ( isPayload && pendingLeadInKeys . has ( key ) ) {
414+ return { mode : "payload-join" , key } ;
415+ }
416+ if ( isIMessageSplitLeadIn ( { text : msg . text , hasMedia } ) ) {
417+ return { mode : "lead-in" , key } ;
418+ }
419+ return { mode : "instant" , key } ;
420+ } ;
421+
362422 const { debouncer : inboundDebouncer } = createChannelInboundDebouncer < {
363423 message : IMessagePayload ;
424+ dm ?: DmCoalesceDecision ;
364425 } > ( {
365426 cfg,
366427 channel : "imessage" ,
@@ -371,21 +432,17 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
371432 if ( ! sender ) {
372433 return null ;
373434 }
435+ // DM coalesce path: key on chat:sender so a lead-in and its payload
436+ // follow-up fall into the same bucket and merge into one agent turn.
437+ if ( entry . dm ) {
438+ return entry . dm . key ;
439+ }
440+ // Group chats / coalesce-disabled use the legacy key so multi-user turn
441+ // structure is preserved.
374442 const conversationId =
375443 msg . chat_id != null
376444 ? `chat:${ msg . chat_id } `
377445 : ( msg . chat_guid ?? msg . chat_identifier ?? "unknown" ) ;
378-
379- // With coalesceSameSenderDms enabled, DMs key on chat:sender so two
380- // distinct user sends — `Dump` followed by a pasted URL that Apple
381- // delivers as a separate row — fall into the same bucket and merge
382- // into one agent turn. Group chats fall through to the legacy key so
383- // shouldDebounce can route them to the instant-dispatch path and
384- // preserve multi-user turn structure.
385- if ( coalesceSameSenderDms && msg . is_group !== true ) {
386- return `imessage:${ accountInfo . accountId } :dm:${ conversationId } :${ sender } ` ;
387- }
388-
389446 return `imessage:${ accountInfo . accountId } :${ conversationId } :${ sender } ` ;
390447 } ,
391448 shouldDebounce : ( entry ) => {
@@ -397,16 +454,16 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
397454 if ( msg . is_from_me === true ) {
398455 return false ;
399456 }
400-
401- // With coalesceSameSenderDms enabled, debounce DM messages aggressively
402- // (text, media, control commands) so split-sends — `Dump <URL>`,
403- // `Save 📎image caption`, and rapid floods — merge into one agent
404- // turn. Group chats keep instant dispatch so the bot stays responsive
405- // when multiple people are typing.
457+ // DM coalesce path: only hold lead-ins and the payloads that join them;
458+ // everything else dispatches instantly.
459+ if ( entry . dm ) {
460+ return entry . dm . mode !== "instant" ;
461+ }
462+ // Group chats keep instant dispatch when coalescing is enabled so the bot
463+ // stays responsive when multiple people are typing.
406464 if ( coalesceSameSenderDms ) {
407- return msg . is_group !== true ;
465+ return false ;
408466 }
409-
410467 // Legacy gate: text-only, no control commands, no media.
411468 return shouldDebounceTextInbound ( {
412469 text : msg . text ,
@@ -420,6 +477,13 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
420477 if ( entries . length === 0 ) {
421478 return ;
422479 }
480+ // A flushing bucket is no longer pending — clear any lead-in keys it held
481+ // so a later payload is classified fresh.
482+ for ( const entry of entries ) {
483+ if ( entry . dm ) {
484+ pendingLeadInKeys . delete ( entry . dm . key ) ;
485+ }
486+ }
423487 if ( entries . length === 1 ) {
424488 await handleMessageNow ( entries [ 0 ] . message ) ;
425489 return ;
@@ -434,6 +498,13 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
434498 }
435499 await handleMessageNow ( combined ) ;
436500 } ,
501+ onCancel : ( entries ) => {
502+ for ( const entry of entries ) {
503+ if ( entry . dm ) {
504+ pendingLeadInKeys . delete ( entry . dm . key ) ;
505+ }
506+ }
507+ } ,
437508 onError : ( err ) => {
438509 runtime . error ?.( `imessage debounce flush failed: ${ String ( err ) } ` ) ;
439510 } ,
@@ -1053,7 +1124,18 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
10531124 if ( ! repairedMessage ) {
10541125 return ;
10551126 }
1056- await inboundDebouncer . enqueue ( { message : repairedMessage } ) ;
1127+ const dm = classifyDmCoalesce ( repairedMessage ) ?? undefined ;
1128+ if ( dm ?. mode === "lead-in" ) {
1129+ // Remember this lead-in so the payload row that follows merges into it
1130+ // instead of dispatching on its own.
1131+ pendingLeadInKeys . add ( dm . key ) ;
1132+ }
1133+ await inboundDebouncer . enqueue ( { message : repairedMessage , dm } ) ;
1134+ if ( dm ?. mode === "payload-join" ) {
1135+ // The payload has merged into the buffered lead-in; flush now so the
1136+ // combined turn dispatches immediately rather than waiting out the window.
1137+ await inboundDebouncer . flushKey ( dm . key ) ;
1138+ }
10571139 } ;
10581140
10591141 await waitForTransportReady ( {
0 commit comments