@peopl-health/nexus 5.67.2 → 5.68.0-dev.12044

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.
@@ -127,10 +127,9 @@ class AssistantProcessor {
127
127
 
128
128
  try {
129
129
  if (!runOptions.shadow && isPatientTurn) {
130
- await cancelPendingFollowUps({ code: thread.code, trace: ctx?.trace || null });
131
- } else {
132
- followUpSnapshot = await readPendingFollowUpsForPatient({ code: thread.code });
130
+ await cancelPendingFollowUps({ code: thread.code, trace: ctx?.trace || null, recoveryOnly: true });
133
131
  }
132
+ followUpSnapshot = await readPendingFollowUpsForPatient({ code: thread.code });
134
133
  } catch (error) {
135
134
  logger.warn('[AssistantProcessor] follow-up state update failed', { code: thread.code, error: error.message });
136
135
  // A proactive turn must not create or send a second follow-up while the plan read is stale.
@@ -317,10 +317,10 @@ class BaseLLMProvider {
317
317
  role: 'developer',
318
318
  content: [
319
319
  '## Seguimiento, recordatorio y contingencia — no son intercambiables',
320
- '- SEGUIMIENTO: tú decides volver a contactar para mantener continuidad cuando ya no falta un dato clínico que pueda cambiar el riesgo. Elige cuándo y redacta la pregunta exacta para esa persona. Usa schedulePatientReminder con reminder_type seguimiento y la utility_basis concreta. Prográmalo cuando indicaste una acción (medicamento de venta libre, autocuidado, educación) cuyo efecto se ve en 24–72 h; cuando la paciente ya dio el dato y quieres saber si el síntoma vuelve o cruza un umbral que nombraste; cuando cerraste un episodio que vale la pena revisar; o cuando tiene un evento próximo (inicio de tratamiento, cita, infusión) cuyo resultado importa. Si en este turno le indicaste un medicamento, una medida de autocuidado o que contacte a alguien (dentista, su médico, urgencias), o le transmitiste un tratamiento que indicó el equipo, decide el seguimiento en este mismo turno: si no queda una red ni un seguimiento activo para ese síntoma, programa uno a 24–48 h con utility_basis agreed_action: si fue un medicamento o una medida, para saber si funcionó; si fue una cita o un estudio, para saber si pudo agendarlo o si ya fue y cuándo. Pedirle «avísame cómo te va» no sustituye ese seguimiento. No lo programes por inactividad sola; cualquier actividad posterior de la paciente lo cancela y entonces respondes a lo nuevo.',
320
+ '- SEGUIMIENTO: tú decides volver a contactar para mantener continuidad cuando ya no falta un dato clínico que pueda cambiar el riesgo. Elige cuándo y redacta la pregunta exacta para esa persona. Usa schedulePatientReminder con reminder_type seguimiento y la utility_basis concreta. Prográmalo cuando indicaste una acción (medicamento de venta libre, autocuidado, educación) cuyo efecto se ve en 24–72 h; cuando la paciente ya dio el dato y quieres saber si el síntoma vuelve o cruza un umbral que nombraste; cuando cerraste un episodio que vale la pena revisar; o cuando tiene un evento próximo (inicio de tratamiento, cita, infusión) cuyo resultado importa. Si en este turno le indicaste un medicamento, una medida de autocuidado o que contacte a alguien (dentista, su médico, urgencias), o le transmitiste un tratamiento que indicó el equipo, decide el seguimiento en este mismo turno: si no queda una red ni un seguimiento activo para ese síntoma, programa uno a 24–48 h con utility_basis agreed_action: si fue un medicamento o una medida, para saber si funcionó; si fue una cita o un estudio, para saber si pudo agendarlo o si ya fue y cuándo. Pedirle «avísame cómo te va» no sustituye ese seguimiento. No lo programes por inactividad sola. Si la paciente vuelve a escribir antes, el seguimiento sigue pendiente y lo verás en «Seguimientos ya programados»: mantenlo, actualízalo o cancélalo según lo que cambió.',
321
321
  '- RECORDATORIO: sólo existe si la paciente pidió explícitamente que se lo recuerdes o aceptó explícitamente tu oferta de recordárselo. Usa schedulePatientReminder con el tipo concreto (medicamento, cita, examen u otros) y exactamente a la hora que pidió. No lo adelantes para que llegue antes de una cita o tarea salvo que ella haya pedido ese adelanto. No infieras un recordatorio porque mencionó o agendó algo, ni lo uses para preguntar cómo sigue o vigilar incertidumbre clínica.',
322
322
  '- CONTINGENCIA: protege una incertidumbre clínica todavía abierta; falta un dato u observación que podría cambiar el grado, la urgencia o la conducta. Usa setContingencyPlan: la contingencia define qué se espera, cuánto silencio tolerar y, si corresponde, la escalada posterior. Nunca la sustituyas por seguimiento ni por recordatorio.',
323
- 'Regla de desempate: si esperas saber “cómo sigue” por continuidad, es seguimiento; si la paciente pidió explícitamente o aceptó que le recuerdes hacer algo, es recordatorio; si esperas un dato porque no tenerlo podría volver insegura la conducta, es contingencia. Una tarea acordada o agendada sin esa petición no es un recordatorio. Una contingencia activa gana sólo mientras siga faltando su dato; cuando la resuelves porque la paciente ya lo dio o porque la preocupación quedó atendida, la vigilancia que queda es un seguimiento, no una red rearmada ni un cierre del tema. Una indicación del equipo de agendar o hacerse una visita o un estudio no resuelve por sí sola una red cuyo dato sigue faltando: la red se mantiene tal como está y no programes un seguimiento aparte mientras siga activa (no se enviaría): pregunta por la visita o el estudio cuando la paciente vuelva a escribir o cuando la red se resuelva.',
323
+ 'Regla de desempate: si esperas saber “cómo sigue” por continuidad, es seguimiento; si la paciente pidió explícitamente o aceptó que le recuerdes hacer algo, es recordatorio; si esperas un dato porque no tenerlo podría volver insegura la conducta, es contingencia. Una tarea acordada o agendada sin esa petición no es un recordatorio. Una contingencia activa gana sólo mientras siga faltando su dato; cuando la resuelves porque la paciente ya lo dio o porque la preocupación quedó atendida, la vigilancia que queda es un seguimiento, no una red rearmada ni un cierre del tema. Una indicación del equipo de agendar o hacerse una visita o un estudio no resuelve por sí sola una red cuyo dato sigue faltando: la red se mantiene tal como está y, además, programa el seguimiento sobre la visita o el estudio: una red abierta no lo detiene.',
324
324
  ].join('\n'),
325
325
  }];
326
326
  }
@@ -440,7 +440,15 @@ class BaseLLMProvider {
440
440
  const pending = Array.isArray(promptVariables?.active_follow_ups) ? promptVariables.active_follow_ups : [];
441
441
  if (!pending.length) return [];
442
442
  const rows = pending.map((item) => `- ${sanitizeInlineText(item.reminderId)} a las ${sanitizeInlineText(item.fireAt)}: ${sanitizeInlineText(item.message)}`);
443
- return [{ role: 'developer', content: ['## Seguimientos ya programados', ...rows, 'No programes ni envíes otro seguimiento que cruce uno de estos planes; actualiza el existente si cambió.'].join('\n') }];
443
+ return [{
444
+ role: 'developer',
445
+ content: [
446
+ '## Seguimientos ya programados',
447
+ ...rows,
448
+ 'Siguen pendientes aunque la paciente haya vuelto a escribir. Si esta conversación ya respondió su pregunta o la volvió innecesaria, cancélalo (schedulePatientReminder con reminder_action_type cancel y su reminder_id); si cambió lo que hay que preguntar o cuándo, actualízalo; si sigue valiendo, déjalo. No crees otro seguimiento que lo cruce.',
449
+ ].join('\n'),
450
+ [TAIL_PLACEMENT]: true,
451
+ }];
444
452
  }
445
453
 
446
454
  _buildClinicalMessage(promptVariables, interpolatedVariables = []) {
@@ -48,6 +48,9 @@ const REMINDER_TYPE_TO_AIRTABLE = {
48
48
  // of pending rows so the predicate cannot drift between them.
49
49
  const PENDING_ROW_FILTER = 'IF({is_active}, "true", "false") = "true", IF({is_sent}, "true", "false") = "false"';
50
50
  const ROUTINE_OUTREACH_FILTER = 'OR({type} = "seguimiento", LEFT({schedule_id}, 9) = "recovery_")';
51
+ const RECOVERY_FILTER = 'LEFT({schedule_id}, 9) = "recovery_"';
52
+ // The fire trigger opens 5 min either side of reminder_date; a row still pending after that was not sent.
53
+ const FIRE_TRIGGER_GRACE_MS = 10 * 60 * 1000;
51
54
 
52
55
  // intake.source only offers self|doctor; team_relay carries "según médico" authority.
53
56
  const INTAKE_SOURCE_TO_AIRTABLE = {
@@ -432,7 +435,9 @@ async function hasPendingReminderForPatient({ code, withinMinutes }) {
432
435
  return Array.isArray(rows) && rows.length > 0;
433
436
  }
434
437
 
435
- async function readPendingFollowUpsForPatient({ code }) {
438
+ // A row whose fire time passed unsent was skipped and is never re-triggered, and one past its expiry
439
+ // can never fire; neither is a plan the agent or the create guard should see.
440
+ async function readPendingFollowUpsForPatient({ code, now = new Date() }) {
436
441
  if (!code) return [];
437
442
  const rows = await airtableService.getRecordByFilter(
438
443
  Monitoreo_ID,
@@ -447,7 +452,30 @@ async function readPendingFollowUpsForPatient({ code }) {
447
452
  message: row.description || '',
448
453
  expiresAt: row.expires_at || null,
449
454
  windowReasoning: row.window_reasoning || null,
450
- }));
455
+ })).filter((plan) => !isAtOrBefore(plan.expiresAt, now) && !isAtOrBefore(plan.fireAt, new Date(now).getTime() - FIRE_TRIGGER_GRACE_MS));
456
+ }
457
+
458
+ function isAtOrBefore(when, now) {
459
+ const at = when ? new Date(when).getTime() : NaN;
460
+ return Number.isFinite(at) && at <= new Date(now).getTime();
461
+ }
462
+
463
+ /*
464
+ * Another check-in about to reach this patient: a net's patient check-in or another routine
465
+ * follow-up, due within `withinMinutes` either side of now so two rows firing together see each
466
+ * other. Patient-requested reminders are left out; they are what she asked for.
467
+ */
468
+ async function hasCheckInNear({ code, withinMinutes, excludeReminderId }) {
469
+ const window = Number(withinMinutes);
470
+ if (!code || !Number.isFinite(window) || window <= 0) return false;
471
+ const rows = await airtableService.getRecordByFilter(
472
+ Monitoreo_ID,
473
+ 'reminder',
474
+ `AND(${fieldEquals('whatsapp_id', code)}, ${PENDING_ROW_FILTER}, OR(RIGHT({schedule_id}, 8) = "-patient", ${ROUTINE_OUTREACH_FILTER}), NOT(${fieldEquals('schedule_id', excludeReminderId)}), ABS(DATETIME_DIFF({reminder_date}, NOW(), 'minutes')) <= ${window})`,
475
+ null,
476
+ ['schedule_id'],
477
+ );
478
+ return Array.isArray(rows) && rows.length > 0;
451
479
  }
452
480
 
453
481
  async function readPendingReminderForPatient({ code, reminderId }) {
@@ -470,14 +498,37 @@ async function readPendingReminderForPatient({ code, reminderId }) {
470
498
  } : null;
471
499
  }
472
500
 
473
- async function cancelPendingFollowUps({ code, trace = null, reason = 'patient_replied' }) {
501
+ // Patient activity retires only turn-loss recovery rows; a seguimiento stays for the agent to keep, update or cancel.
502
+ async function cancelPendingFollowUps({ code, trace = null, reason = 'patient_replied', recoveryOnly = false }) {
474
503
  return standDownRow({
475
504
  code,
476
505
  trace,
477
506
  table: 'reminder',
478
507
  planId: null,
479
508
  standDownReason: reason,
480
- filter: `AND(${fieldEquals('whatsapp_id', code)}, ${ROUTINE_OUTREACH_FILTER}, ${PENDING_ROW_FILTER})`,
509
+ filter: `AND(${fieldEquals('whatsapp_id', code)}, ${recoveryOnly ? RECOVERY_FILTER : ROUTINE_OUTREACH_FILTER}, ${PENDING_ROW_FILTER})`,
510
+ });
511
+ }
512
+
513
+ async function cancelFollowUp({ code, reminderId, trace = null, reason, isLive = null }) {
514
+ if (!code || !reminderId) return false;
515
+ return auditedWrite({
516
+ trace,
517
+ code,
518
+ isLive,
519
+ table: 'reminder',
520
+ meta: { reminderId, standDown: true },
521
+ write: async () => {
522
+ const updated = await airtableService.updateRecordByFilter(
523
+ Monitoreo_ID,
524
+ 'reminder',
525
+ `AND(${fieldEquals('whatsapp_id', code)}, ${fieldEquals('schedule_id', reminderId)}, ${ROUTINE_OUTREACH_FILTER}, ${PENDING_ROW_FILTER})`,
526
+ { is_active: false, stand_down_reason: reason },
527
+ code,
528
+ );
529
+ if (!updated?.length) throw new Error(`pending follow-up ${reminderId} was not stood down`);
530
+ return updated;
531
+ },
481
532
  });
482
533
  }
483
534
 
@@ -637,6 +688,8 @@ module.exports = {
637
688
  readPendingFollowUpsForPatient,
638
689
  readPendingReminderForPatient,
639
690
  cancelPendingFollowUps,
691
+ cancelFollowUp,
692
+ hasCheckInNear,
640
693
  markReminderSent,
641
694
  recordContingencyEscalation,
642
695
  cancelContingencyRows,
@@ -2,7 +2,7 @@ const moment = require('moment-timezone');
2
2
 
3
3
  const { isShadowRun } = require('../helpers/shadowHelper');
4
4
  const { dispatchReminder } = require('../services/reminderDispatchService');
5
- const { recordReminder, readPendingFollowUpsForPatient } = require('../services/clinicalAirtableService');
5
+ const { recordReminder, readPendingFollowUpsForPatient, cancelFollowUp } = require('../services/clinicalAirtableService');
6
6
  const { isWithinTriageProtectionWindow } = require('../services/outreachSweepService');
7
7
  const { UTILITY_BASES, sanitizeVariable } = require('../services/outreachCarrierService');
8
8
  const { logger } = require('../../utils/logger');
@@ -10,7 +10,7 @@ const { DEFAULT_TIMEZONE } = require('../../utils/scheduleUtils');
10
10
 
11
11
  const definition = {
12
12
  name: 'schedulePatientReminder',
13
- description: '**Does:** Creates or updates one agent-authored WhatsApp reminder. An ordinary reminder (medication, appointment, exam, or other task) is allowed only when the patient explicitly asks for or explicitly accepts that reminder. Do not infer a reminder merely because the patient mentioned or scheduled something. Schedule it at the exact date and time the patient requested. Never move it earlier to precede an appointment or task unless the patient explicitly requested that offset. On any patient conversation—not only a triage—or on a proactive review, use reminder_type seguimiento when this turn decides both when to check in again and the exact patient-facing words. Schedule one whenever a later contact materially helps continuity and has a concrete utility_basis — typically: you recommended an action (OTC medication, self-care, education) whose effect shows within 24–72h; the patient already gave the deciding datum and you want to know whether the symptom comes back or crosses a threshold you named; you closed a symptom episode worth checking on; or the patient has an upcoming event (starting a treatment, an appointment, an infusion) whose outcome matters. Inactivity alone is not a basis.\\n\\nA seguimiento is a lightweight engagement plan: it is capped at 600 characters, never truncated, expires 24 hours after its chosen fire time, and cannot be created while another seguimiento is active. Any patient activity cancels it.\\n\\n**When NOT to call:** when the reminder guards unresolved clinical uncertainty, use setContingencyPlan instead; that plan owns its silence window and lifecycle. Never update a contingency-owned `plan_…-patient` reminder here.\\n\\nRequired: future patient-local reminder_date, reminder_description, reminder_type, action type, reminder_id for updates, and utility_basis for seguimiento.',
13
+ description: '**Does:** Creates or updates one agent-authored WhatsApp reminder. An ordinary reminder (medication, appointment, exam, or other task) is allowed only when the patient explicitly asks for or explicitly accepts that reminder. Do not infer a reminder merely because the patient mentioned or scheduled something. Schedule it at the exact date and time the patient requested. Never move it earlier to precede an appointment or task unless the patient explicitly requested that offset. On any patient conversation—not only a triage—or on a proactive review, use reminder_type seguimiento when this turn decides both when to check in again and the exact patient-facing words. Schedule one whenever a later contact materially helps continuity and has a concrete utility_basis — typically: you recommended an action (OTC medication, self-care, education) whose effect shows within 24–72h; the patient already gave the deciding datum and you want to know whether the symptom comes back or crosses a threshold you named; you closed a symptom episode worth checking on; or the patient has an upcoming event (starting a treatment, an appointment, an infusion) whose outcome matters. Inactivity alone is not a basis.\\n\\nA seguimiento is a lightweight engagement plan: it is capped at 600 characters, never truncated, fires at most 48 hours ahead, expires 24 hours after its chosen fire time, and cannot be created while another seguimiento is active. It stays pending when the patient writes again: later turns list it under «Seguimientos ya programados» so you keep it, update it, or cancel it (reminder_action_type cancel) when the conversation already answered it.\\n\\n**When NOT to call:** when the reminder guards unresolved clinical uncertainty, use setContingencyPlan instead; that plan owns its silence window and lifecycle. Never update a contingency-owned `plan_…-patient` reminder here.\\n\\nRequired: future patient-local reminder_date, reminder_description, reminder_type, action type, reminder_id for updates, and utility_basis for seguimiento.',
14
14
  strict: true,
15
15
  parameters: {
16
16
  type: 'object',
@@ -26,12 +26,12 @@ const definition = {
26
26
  reminder_type: {
27
27
  type: 'string',
28
28
  enum: ['medicamento', 'exámen', 'examen', 'cita', 'check_in', 'seguimiento', 'otros'],
29
- description: '\'seguimiento\' = the agent chooses to contact later with an exact continuity/engagement question once no datum that could raise the grade is still missing; patient activity cancels it. \'medicamento\', \'cita\', \'examen\'/\'exámen\', and \'otros\' require an explicit patient request or acceptance to be reminded. A mention or scheduled event alone is insufficient. \'check_in\' is legacy compatibility; use seguimiento for new agent-owned follow-ups. Never use this tool to guard unresolved clinical uncertainty—use setContingencyPlan.',
29
+ description: '\'seguimiento\' = the agent chooses to contact later with an exact continuity/engagement question once no datum that could raise the grade is still missing; it stays pending until sent, expired, updated or cancelled by you. \'medicamento\', \'cita\', \'examen\'/\'exámen\', and \'otros\' require an explicit patient request or acceptance to be reminded. A mention or scheduled event alone is insufficient. \'check_in\' is legacy compatibility; use seguimiento for new agent-owned follow-ups. Never use this tool to guard unresolved clinical uncertainty—use setContingencyPlan.',
30
30
  },
31
31
  reminder_action_type: {
32
32
  type: 'string',
33
- enum: ['create', 'update'],
34
- description: '\'create\' = new reminder. \'update\' = modify an existing reminder (change date/time/description) — pass its id in `reminder_id`.',
33
+ enum: ['create', 'update', 'cancel'],
34
+ description: '\'create\' = new reminder. \'update\' = modify an existing reminder (change date/time/description) — pass its id in `reminder_id`. \'cancel\' = retire a pending seguimiento that the conversation already answered or made unnecessary — pass its id in `reminder_id`; the date and description are ignored.',
35
35
  },
36
36
  reminder_id: {
37
37
  type: ['string', 'null'],
@@ -51,6 +51,8 @@ const definition = {
51
51
  const REMINDER_TYPES = definition.parameters.properties.reminder_type.enum;
52
52
  const ACTION_TYPES = definition.parameters.properties.reminder_action_type.enum;
53
53
 
54
+ const MAX_FOLLOW_UP_LEAD_HOURS = 48;
55
+
54
56
  const ISO_DATETIME = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d{1,6})?)?$/;
55
57
  const FIRE_AT_FORMATS = ['YYYY-MM-DDTHH:mm:ss', 'YYYY-MM-DDTHH:mm', 'YYYY-MM-DD HH:mm:ss', 'YYYY-MM-DD HH:mm'];
56
58
 
@@ -76,6 +78,9 @@ async function handler(args = {}, context = {}) {
76
78
  }
77
79
  const { turnId } = runtime;
78
80
 
81
+ const requestedAction = (args?.reminder_action_type || '').toLowerCase();
82
+ if (requestedAction === 'cancel') return await cancelSeguimiento(args, runtime);
83
+
79
84
  const reminderDate = (args?.reminder_date || '').trim();
80
85
  const description = sanitizeVariable(args?.reminder_description || '');
81
86
  if (!reminderDate || !description) {
@@ -116,6 +121,9 @@ async function handler(args = {}, context = {}) {
116
121
  if (!fireMoment.isAfter(moment.tz(timeZone))) {
117
122
  return JSON.stringify({ success: false, error: `reminder_date must be in the future (${timeZone}) — got '${reminderDate}'.`, data: {} });
118
123
  }
124
+ if (reminderType === 'seguimiento' && fireMoment.isAfter(moment.tz(timeZone).add(MAX_FOLLOW_UP_LEAD_HOURS, 'hours'))) {
125
+ return JSON.stringify({ success: false, error: `seguimiento must fire within ${MAX_FOLLOW_UP_LEAD_HOURS} hours; ask about a later event in a seguimiento closer to it.`, data: {} });
126
+ }
119
127
  const isRoutineOutreach = reminderType === 'seguimiento' || laneType === 'otros';
120
128
  if (isRoutineOutreach && isWithinTriageProtectionWindow(fireMoment.toDate())) {
121
129
  return JSON.stringify({ success: false, error: 'routine outreach cannot be scheduled during the 07:30-11:00 triage protection window; choose 11:00 or later.', data: {} });
@@ -202,6 +210,26 @@ async function handler(args = {}, context = {}) {
202
210
  }
203
211
  }
204
212
 
213
+ async function cancelSeguimiento(args, runtime) {
214
+ const reminderId = typeof args?.reminder_id === 'string' ? args.reminder_id.trim() : '';
215
+ if (!reminderId) {
216
+ return JSON.stringify({ success: false, error: 'cancel requires the reminder_id of a pending seguimiento.', data: {} });
217
+ }
218
+ const { patientCode, trace } = runtime;
219
+ const notPending = () => JSON.stringify({ success: false, error: 'not_pending — only a pending seguimiento listed under «Seguimientos ya programados» can be cancelled.', data: { reminder_id: reminderId } });
220
+ if (!reminderId.startsWith('followup_')) return notPending();
221
+ if (patientCode && !isShadowRun()) {
222
+ const pending = await readPendingFollowUpsForPatient({ code: patientCode });
223
+ if (!pending.some((plan) => plan.reminderId === reminderId)) return notPending();
224
+ if (!await cancelFollowUp({ code: patientCode, reminderId, trace, reason: 'agent_cancelled', isLive: runtime.isLive?.bind(runtime) })) {
225
+ return JSON.stringify({ success: false, error: 'seguimiento cancellation failed; retry', data: { reminder_id: reminderId } });
226
+ }
227
+ }
228
+ const existing = Array.isArray(trace.signals?.reminderIntents) ? trace.signals.reminderIntents : [];
229
+ trace.setSignals({ reminderIntents: [...existing, { reminderId, reminderType: 'seguimiento', actionType: 'cancel', channel: 'whatsapp' }] });
230
+ return JSON.stringify({ success: true, data: { reminder_id: reminderId, action_type: 'cancel' } });
231
+ }
232
+
205
233
  module.exports = {
206
234
  definition,
207
235
  handler,
@@ -12,7 +12,7 @@ const MAX_PATIENT_SILENCE_MIN = 1380;
12
12
 
13
13
  const definition = {
14
14
  name: 'setContingencyPlan',
15
- description: `**Does:** Manages the contingency safety net guarding an open case or cluster, selected via \`action\`. A net is a single promise: if the patient goes quiet, you write to them again.\n- \`arm\` (default): arms a new net — a CarePlan anchor plus the follow-up check-in that goes out to the patient after true silence.\n- \`resolve\`: stands an active net down because this turn's conversation addressed the guarded concern, or it is no longer clinically relevant. Requires \`plan_id\` + \`resolution_reasoning\`.\n- \`rearm\`: replaces the target's active net with this new definition (fresh silence window anchored now) — same required fields as \`arm\`. Use when new information changes the concern, window, or message.\n\n**Required inputs:** \`arm\`/\`rearm\`: \`concern\`, \`awaiting_characterization\`; at least one of \`related_case_id\` / \`related_cluster_id\`; \`patient_followup_reminder\` \`${STEP_SHAPE}\` (message + a positive \`fires_after_silence_minutes\` <= ${MAX_PATIENT_SILENCE_MIN}, plus \`window_reasoning\`: one sentence on why that wait). \`resolve\`: \`plan_id\` + \`resolution_reasoning\`.\n\n**When to call:** \`arm\` when, read against the symptom's CTCAE grade scale, a datum is still missing that could put the case at a grade above your current read. If everything needed to settle the severity is already known, do not arm. The rubric is the test, not how alarming the symptom sounds.\n- Missing information could move it from G1 to G2/G3 → arm, and wait for exactly that datum.\n- Everything needed to rule out the higher grades is already known → do not arm.\n- Serious AND fully assessed → escalate. A finished assessment needs a person, not a check-in.\n\nFind the gap in the \`grade_scale\` you graded against: which clause of the next grade up can you neither confirm nor rule out? Name that datum in \`awaiting_characterization\`, concrete enough that the next turn can tell whether their message addressed it. It is an answer the patient still owes about the symptom as it stands now — what qualifies it is that it moves the grade, not the form it takes. A pending detail that cannot change the grade (an administrative confirmation, a name, an appointment) is not a reason to arm. Neither is a watch that starts after the patient already gave that datum — whether the symptom comes back, crosses a threshold you named, or eases with what you recommended: that is continuity, not uncertainty, so schedule a \`seguimiento\` with \`schedulePatientReminder\` instead, and do not re-arm a net you just resolved to cover it. Set \`resolves_on\` to match: \`reply\` for an answer they owe, \`observation\` for an event their next message may not carry.\n\nWaiting on the care team does NOT satisfy this test — a non-routine escalation normally arms its own net, so team follow-up is not this tool's job. A \`routine\` escalation does not — it routes to the team's ticket lane and leaves no net, so on those turns the ceiling test decides on its own merits. Do not condition it on predicting whether the patient will go quiet: that cannot be known, and a net they turn out not to need never reaches them — it steps aside every time they write. \`resolve\` when the patient's message answered what the net was waiting to observe; \`rearm\` when the net's definition went stale. Resolve a net only when its awaited datum arrived or the concern it guards was addressed. A care-team indication to book or do a visit or test does not by itself address it: if the net still awaits a grade-changing observation, keep the net as it is and do not schedule a separate seguimiento while it is active (it would not be delivered); ask about the visit or test when the patient next writes or once the net resolves. When a net is resolved and the team gave an indication, what remains open is the indication: schedule a \`seguimiento\` with \`schedulePatientReminder\` (\`utility_basis: agreed_action\`) at 24–48 h instead of treating the topic as closed — for a medication or measure, to learn whether it works; for a visit or test, to learn whether it was booked or done and when (ask about the result later, once it has happened).\n\n**When NOT to call:** \`arm\` on a target that already has an active plan (fails hard — use \`resolve\` or \`rearm\`) or whose case/cluster is closed/dissolved; \`resolve\` while the concern is still unobserved (closing the case retires its net automatically). This tool does NOT notify the care team — to reach a human, use the escalation tools.\n\n**Returns:** \`arm\`/\`rearm\` → \`{plan_id}\` (\`rearm\` adds \`superseded_plan_ids\` when it replaced a net). \`resolve\` → \`{plan_id}\`, with \`already_resolved: true\` when the net was retired earlier — an idempotent success, not an error. Fails hard with \`case_not_found_or_wrong_patient\` / \`cluster_not_found_or_wrong_patient\`, \`target_not_open\`, \`active_plan_already_exists_for_case\` / \`_for_cluster\` (arm only), \`plan_not_found_or_wrong_patient\`, \`store_failed_retry_next_turn\`, or \`stand_down_failed_retry_next_turn\`.\n\n**Side effects:** records the action on the turn trace; writes the FHIR CarePlan + patient CommunicationRequest + Provenance; arms or deactivates the dispatch row — the check-in goes out over WhatsApp only if the patient stayed silent through its window, and is pushed back each time they write. Nets also stand down when their case closes or cluster dissolves, and expire on their own after a bounded multiple of the check-in window.`,
15
+ description: `**Does:** Manages the contingency safety net guarding an open case or cluster, selected via \`action\`. A net is a single promise: if the patient goes quiet, you write to them again.\n- \`arm\` (default): arms a new net — a CarePlan anchor plus the follow-up check-in that goes out to the patient after true silence.\n- \`resolve\`: stands an active net down because this turn's conversation addressed the guarded concern, or it is no longer clinically relevant. Requires \`plan_id\` + \`resolution_reasoning\`.\n- \`rearm\`: replaces the target's active net with this new definition (fresh silence window anchored now) — same required fields as \`arm\`. Use when new information changes the concern, window, or message.\n\n**Required inputs:** \`arm\`/\`rearm\`: \`concern\`, \`awaiting_characterization\`; at least one of \`related_case_id\` / \`related_cluster_id\`; \`patient_followup_reminder\` \`${STEP_SHAPE}\` (message + a positive \`fires_after_silence_minutes\` <= ${MAX_PATIENT_SILENCE_MIN}, plus \`window_reasoning\`: one sentence on why that wait). \`resolve\`: \`plan_id\` + \`resolution_reasoning\`.\n\n**When to call:** \`arm\` when, read against the symptom's CTCAE grade scale, a datum is still missing that could put the case at a grade above your current read. If everything needed to settle the severity is already known, do not arm. The rubric is the test, not how alarming the symptom sounds.\n- Missing information could move it from G1 to G2/G3 → arm, and wait for exactly that datum.\n- Everything needed to rule out the higher grades is already known → do not arm.\n- Serious AND fully assessed → escalate. A finished assessment needs a person, not a check-in.\n\nFind the gap in the \`grade_scale\` you graded against: which clause of the next grade up can you neither confirm nor rule out? Name that datum in \`awaiting_characterization\`, concrete enough that the next turn can tell whether their message addressed it. It is an answer the patient still owes about the symptom as it stands now — what qualifies it is that it moves the grade, not the form it takes. A pending detail that cannot change the grade (an administrative confirmation, a name, an appointment) is not a reason to arm. Neither is a watch that starts after the patient already gave that datum — whether the symptom comes back, crosses a threshold you named, or eases with what you recommended: that is continuity, not uncertainty, so schedule a \`seguimiento\` with \`schedulePatientReminder\` instead, and do not re-arm a net you just resolved to cover it. Set \`resolves_on\` to match: \`reply\` for an answer they owe, \`observation\` for an event their next message may not carry.\n\nWaiting on the care team does NOT satisfy this test — a non-routine escalation normally arms its own net, so team follow-up is not this tool's job. A \`routine\` escalation does not — it routes to the team's ticket lane and leaves no net, so on those turns the ceiling test decides on its own merits. Do not condition it on predicting whether the patient will go quiet: that cannot be known, and a net they turn out not to need never reaches them — it steps aside every time they write. \`resolve\` when the patient's message answered what the net was waiting to observe; \`rearm\` when the net's definition went stale. Resolve a net only when its awaited datum arrived or the concern it guards was addressed. A care-team indication to book or do a visit or test does not by itself address it: if the net still awaits a grade-changing observation, keep the net as it is and also schedule the seguimiento about the visit or test — an open net does not hold it back. When a net is resolved and the team gave an indication, what remains open is the indication: schedule a \`seguimiento\` with \`schedulePatientReminder\` (\`utility_basis: agreed_action\`) at 24–48 h instead of treating the topic as closed — for a medication or measure, to learn whether it works; for a visit or test, to learn whether it was booked or done and when (ask about the result later, once it has happened).\n\n**When NOT to call:** \`arm\` on a target that already has an active plan (fails hard — use \`resolve\` or \`rearm\`) or whose case/cluster is closed/dissolved; \`resolve\` while the concern is still unobserved (closing the case retires its net automatically). This tool does NOT notify the care team — to reach a human, use the escalation tools.\n\n**Returns:** \`arm\`/\`rearm\` → \`{plan_id}\` (\`rearm\` adds \`superseded_plan_ids\` when it replaced a net). \`resolve\` → \`{plan_id}\`, with \`already_resolved: true\` when the net was retired earlier — an idempotent success, not an error. Fails hard with \`case_not_found_or_wrong_patient\` / \`cluster_not_found_or_wrong_patient\`, \`target_not_open\`, \`active_plan_already_exists_for_case\` / \`_for_cluster\` (arm only), \`plan_not_found_or_wrong_patient\`, \`store_failed_retry_next_turn\`, or \`stand_down_failed_retry_next_turn\`.\n\n**Side effects:** records the action on the turn trace; writes the FHIR CarePlan + patient CommunicationRequest + Provenance; arms or deactivates the dispatch row — the check-in goes out over WhatsApp only if the patient stayed silent through its window, and is pushed back each time they write. Nets also stand down when their case closes or cluster dissolves, and expire on their own after a bounded multiple of the check-in window.`,
16
16
  strict: false,
17
17
  parameters: {
18
18
  type: 'object',
@@ -43,8 +43,10 @@ const {
43
43
  readPendingReminderForPatient,
44
44
  cancelPendingFollowUps,
45
45
  markReminderSent,
46
+ cancelFollowUp,
47
+ hasCheckInNear,
46
48
  } = require('../clinical/services/clinicalAirtableService');
47
- const { cancelContingencies, snapshotContingencies } = require('../clinical/services/contingencyDispatchService');
49
+ const { cancelContingencies } = require('../clinical/services/contingencyDispatchService');
48
50
  const { isContingencyCancelOnReplyLive } = require('../clinical/flags/contingencyFlags');
49
51
  const { isLlmMonitorEnabled } = require('../clinical/flags/llmMonitorConfig');
50
52
 
@@ -56,6 +58,8 @@ const { invokeInteractiveRoute } = require('./interactiveRouteService');
56
58
 
57
59
  const { PhiProcessor } = require('./PhiProcessor');
58
60
 
61
+ const FOLLOW_UP_SPACING_MIN = 180;
62
+
59
63
  const unbatchedRoutes = {
60
64
  __proto__: null,
61
65
  flow: 'handleFlow',
@@ -597,9 +601,10 @@ class NexusMessaging {
597
601
  const plainText = messageData.body;
598
602
  messageData = { ...messageData, body };
599
603
  if (chatId) {
600
- // This is the common ingress for text, media, buttons, lists, and flows. Retire routine
601
- // follow-ups here, before specialized handlers split off, so all patient activity counts.
602
- await cancelPendingFollowUps({ code: chatId }).catch(error =>
604
+ // This is the common ingress for text, media, buttons, lists, and flows. Retire turn-loss
605
+ // recovery here, before specialized handlers split off, so all patient activity counts.
606
+ // A seguimiento survives: the next turn shows it to the agent, who keeps, updates or cancels it.
607
+ await cancelPendingFollowUps({ code: chatId, recoveryOnly: true }).catch(error =>
603
608
  logger.error('[processIncomingMessage] Follow-up stand-down failed', { code: chatId, error: error.message })
604
609
  );
605
610
  if (isContingencyCancelOnReplyLive()) {
@@ -1045,7 +1050,7 @@ class NexusMessaging {
1045
1050
  * Delivers a message the agent previously authored and scheduled with schedulePatientReminder.
1046
1051
  * The clock only wakes this method; it does not choose copy or timing. Running inside the same
1047
1052
  * patient queue as inbound WhatsApp traffic prevents an already-received message from crossing
1048
- * the follow-up. A clinical contingency always outranks this routine engagement plan.
1053
+ * the follow-up.
1049
1054
  */
1050
1055
  async processScheduledFollowUp(code, { reminderId, firstName, now = new Date() } = {}) {
1051
1056
  if (!code || !reminderId) throw new Error('code and reminderId are required');
@@ -1068,19 +1073,20 @@ class NexusMessaging {
1068
1073
  }
1069
1074
  const expiresAt = plan.expiresAt ? new Date(plan.expiresAt) : null;
1070
1075
  if (expiresAt && !Number.isNaN(expiresAt.getTime()) && expiresAt.getTime() <= startedAt.getTime()) {
1076
+ await cancelFollowUp({ code, reminderId, reason: 'expired' });
1071
1077
  result = { decision: 'skipped', holdReason: 'expired', accepted: false, delivered: false };
1072
1078
  return result;
1073
1079
  }
1074
1080
 
1075
- const contingencies = await snapshotContingencies({ patientCode: code });
1076
- if ((contingencies?.surviving || []).length > 0) {
1077
- result = { decision: 'skipped', holdReason: 'active_contingency', accepted: false, delivered: false };
1078
- return result;
1079
- }
1080
-
1081
- const collision = await routineOutreachCollisionReason(code, { now: startedAt });
1082
- if (collision) {
1083
- result = { decision: 'skipped', holdReason: collision, accepted: false, delivered: false };
1081
+ // An open net alone does not hold a seguimiento: the two ask different things. Hold only when
1082
+ // another check-in is about to reach the patient. The fire trigger does not come back for a
1083
+ // skipped row, so a skip retires it with its reason instead of leaving a plan that never sends.
1084
+ const holdReason = await hasCheckInNear({ code, withinMinutes: FOLLOW_UP_SPACING_MIN, excludeReminderId: reminderId })
1085
+ ? 'check_in_near'
1086
+ : await routineOutreachCollisionReason(code, { now: startedAt });
1087
+ if (holdReason) {
1088
+ await cancelFollowUp({ code, reminderId, reason: `skipped_${holdReason}` });
1089
+ result = { decision: 'skipped', holdReason, accepted: false, delivered: false };
1084
1090
  return result;
1085
1091
  }
1086
1092
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "5.67.2",
3
+ "version": "5.68.0-dev.12044",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",