@peopl-health/nexus 3.12.0 → 3.13.1

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.
@@ -1,11 +1,6 @@
1
1
  const { logger } = require('../utils/logger');
2
2
  const { Message } = require('../models/messageModel');
3
3
 
4
- const VALID_HOOKS = ['preBatch', 'postBatch', 'onBatchError', 'prePromptBuild'];
5
-
6
- /**
7
- * Manages message batching, processing locks, run abandonment, and lifecycle hooks.
8
- */
9
4
  class BatchingManager {
10
5
  constructor({ provider = null, config = {} }) {
11
6
  this.provider = provider;
@@ -20,33 +15,7 @@ class BatchingManager {
20
15
  this.activeRequests = new Map();
21
16
  this.abandonedRuns = new Set();
22
17
  this.typingIntervals = new Map();
23
-
24
- this._hooks = {
25
- preBatch: [],
26
- postBatch: [],
27
- onBatchError: [],
28
- prePromptBuild: [],
29
- };
30
-
31
- if (config.hooks) {
32
- for (const [name, handlers] of Object.entries(config.hooks)) {
33
- const fns = Array.isArray(handlers) ? handlers : [handlers];
34
- for (const fn of fns) {
35
- this.addHook(name, fn);
36
- }
37
- }
38
- }
39
- }
40
-
41
- addHook(name, fn) {
42
- if (!VALID_HOOKS.includes(name)) {
43
- throw new Error(`[BatchingManager] Unknown hook: "${name}". Valid hooks: ${VALID_HOOKS.join(', ')}`);
44
- }
45
- if (typeof fn !== 'function') {
46
- throw new Error(`[BatchingManager] Hook "${name}" must be a function`);
47
- }
48
- this._hooks[name].push(fn);
49
- return this;
18
+ this.chatQueues = new Map();
50
19
  }
51
20
 
52
21
  setProvider(provider) {
@@ -70,28 +39,33 @@ class BatchingManager {
70
39
 
71
40
  if (this.config.immediateRestart) {
72
41
  this._clearProcessingState(chatId);
73
- await this._processWithLock(chatId, processingFn, sendResponseFn);
42
+ this.processingLocks.set(chatId, true);
43
+ await this._runWithLock(chatId, processingFn, sendResponseFn);
74
44
  }
75
45
  }
76
46
  return;
77
47
  }
78
48
 
79
49
  this.processingLocks.set(chatId, true);
80
- await this._processWithLock(chatId, processingFn, sendResponseFn);
50
+ await this._runWithLock(chatId, processingFn, sendResponseFn);
81
51
  }
82
52
 
83
- async _processWithLock(chatId, processingFn, sendResponseFn) {
53
+ async enqueueProcessing(chatId, processingFn, sendResponseFn) {
54
+ await this._runWithLock(chatId, processingFn, sendResponseFn);
55
+ }
56
+
57
+ async _runWithLock(chatId, processingFn, sendResponseFn) {
58
+ const prev = this.chatQueues.get(chatId) || Promise.resolve();
59
+ let resolveGate;
60
+ const gate = new Promise(r => { resolveGate = r; });
61
+ this.chatQueues.set(chatId, gate);
62
+ await prev;
63
+
84
64
  this.processingLocks.set(chatId, true);
85
65
  const runId = `run_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
86
66
  this.activeRequests.set(chatId, runId);
87
67
 
88
68
  try {
89
- const pre = await this._runSequentialHooks(chatId, runId);
90
- if (pre?.skip) {
91
- logger.debug('[BatchingManager] Batch skipped by preBatch hook', { chatId, runId });
92
- return;
93
- }
94
-
95
69
  await this._startTypingRefresh(chatId, runId);
96
70
  if (this._checkAbandoned(runId)) return;
97
71
 
@@ -101,12 +75,12 @@ class BatchingManager {
101
75
  if (sendResponseFn && result) await sendResponseFn(result);
102
76
  } catch (error) {
103
77
  logger.error('[BatchingManager] Error processing messages', { chatId, error: error.message });
104
- this._runDeferredHooks('onBatchError', chatId, error);
105
78
  } finally {
106
79
  if (this.activeRequests.get(chatId) === runId) {
107
80
  this._clearProcessingState(chatId);
108
- this._runDeferredHooks('postBatch', chatId);
109
81
  }
82
+ resolveGate();
83
+ if (this.chatQueues.get(chatId) === gate) this.chatQueues.delete(chatId);
110
84
  if (this.abandonedRuns.size > 100) {
111
85
  const toKeep = [...this.abandonedRuns].slice(-20);
112
86
  this.abandonedRuns.clear();
@@ -121,63 +95,6 @@ class BatchingManager {
121
95
  this._stopTyping(chatId);
122
96
  }
123
97
 
124
- async _runSequentialHooks(chatId, runId) {
125
- const hooks = this._hooks.preBatch;
126
- if (hooks.length === 0) return;
127
-
128
- const context = { chatId, runId, metadata: {} };
129
-
130
- for (const fn of hooks) {
131
- const result = await fn(context);
132
- if (result?.skip) return { skip: true };
133
- }
134
- }
135
-
136
- async runPromptBuildHooks(context) {
137
- const hooks = this._hooks.prePromptBuild;
138
- if (hooks.length === 0) return null;
139
-
140
- let additionalInstructions = '';
141
- const additionalMessages = [];
142
- let toolChoice = null;
143
- for (const fn of hooks) {
144
- const result = await fn(context);
145
- if (result?.additionalInstructions) {
146
- additionalInstructions += additionalInstructions
147
- ? `\n\n${result.additionalInstructions}`
148
- : result.additionalInstructions;
149
- }
150
- if (Array.isArray(result?.additionalMessages) && result.additionalMessages.length > 0) {
151
- additionalMessages.push(...result.additionalMessages);
152
- }
153
- if (result?.toolChoice) {
154
- toolChoice = result.toolChoice;
155
- }
156
- }
157
- return { additionalInstructions, additionalMessages, toolChoice };
158
- }
159
-
160
- _runDeferredHooks(name, chatId, error) {
161
- const hooks = this._hooks[name];
162
- if (hooks.length === 0) return;
163
-
164
- setImmediate(() => {
165
- if (this.isProcessing(chatId)) return;
166
-
167
- const context = { chatId };
168
- if (error) context.error = error;
169
-
170
- Promise.allSettled(hooks.map(fn => fn(context)))
171
- .then(results => {
172
- for (const result of results) {
173
- if (result.status === 'rejected') {
174
- logger.error(`[BatchingManager] ${name} hook failed`, { chatId, error: result.reason?.message });
175
- }
176
- }
177
- });
178
- });
179
- }
180
-
181
98
  _stopTyping(chatId) {
182
99
  const interval = this.typingIntervals.get(chatId);
183
100
  if (interval) {
@@ -18,6 +18,7 @@ const { addMsgAssistant, replyAssistant } = require('../services/assistantServic
18
18
  const { hasPreprocessingHandler, invokePreprocessingHandler } = require('../services/preprocessingService');
19
19
 
20
20
  const { BatchingManager } = require('../core/BatchingManager');
21
+ const { ProcessingPipeline } = require('../core/ProcessingPipeline');
21
22
  const { AssistantProcessor } = require('../core/AssistantProcessor');
22
23
 
23
24
  const { createQueueAdapter } = require('../queue');
@@ -65,6 +66,8 @@ class NexusMessaging {
65
66
  config: this.batchingConfig
66
67
  });
67
68
 
69
+ this.pipeline = new ProcessingPipeline(this.batchingConfig.hooks);
70
+
68
71
  const queueType = config.queue?.type || 'local';
69
72
  const queueConfig = config.queue?.config || {};
70
73
  this.queueAdapter = createQueueAdapter(queueType, queueConfig);
@@ -155,6 +158,7 @@ class NexusMessaging {
155
158
  getLLMProvider() { return this.llmProvider; }
156
159
  getEventBus() { return this.events; }
157
160
  getBatchingManager() { return this.batchingManager; }
161
+ getPipeline() { return this.pipeline; }
158
162
  getAssistantProcessor() { return this.assistantProcessor; }
159
163
  getQueueAdapter() { return this.queueAdapter; }
160
164
  isConnected() { return this.provider?.getConnectionStatus() ?? false; }
@@ -351,9 +355,17 @@ class NexusMessaging {
351
355
 
352
356
  async _handleWithCheckAfter(chatId) {
353
357
  const processingFn = async (runId) => {
354
- const prePromptResult = await this.batchingManager.runPromptBuildHooks({ chatId, runId });
355
- const shouldFinalize = () => this.batchingManager.isActiveRun(chatId, runId);
356
- return await this._processMessages(chatId, () => this.assistantProcessor.process({ code: chatId, runOptions: { runId, prePromptResult } }), shouldFinalize);
358
+ const shouldContinue = () => this.batchingManager.isActiveRun(chatId, runId);
359
+
360
+ return await this.pipeline.run(
361
+ { chatId, runId, type: 'message' },
362
+ async (preProcessResult) => {
363
+ return await this._processMessages(chatId, () =>
364
+ this.assistantProcessor.process({ code: chatId, runOptions: { runId, prePromptResult: preProcessResult } })
365
+ , shouldContinue);
366
+ },
367
+ shouldContinue
368
+ );
357
369
  };
358
370
 
359
371
  const sendResponseFn = async (result) => {
@@ -0,0 +1,145 @@
1
+ const { logger } = require('../utils/logger');
2
+
3
+ const VALID_HOOKS = ['preProcess', 'postProcess'];
4
+
5
+ const LEGACY_MAP = {
6
+ preBatch: 'preProcess',
7
+ prePromptBuild: 'preProcess',
8
+ postBatch: 'postProcess',
9
+ onBatchError: 'postProcess',
10
+ };
11
+
12
+ class ProcessingPipeline {
13
+ constructor(hooks = {}) {
14
+ this._hooks = {
15
+ preProcess: [],
16
+ postProcess: [],
17
+ };
18
+
19
+ this._hookMeta = new WeakMap();
20
+
21
+ if (hooks && typeof hooks === 'object') {
22
+ for (const [name, handlers] of Object.entries(hooks)) {
23
+ const fns = Array.isArray(handlers) ? handlers : [handlers];
24
+ for (const fn of fns) {
25
+ this.addHook(name, fn);
26
+ }
27
+ }
28
+ }
29
+ }
30
+
31
+ addHook(name, fn) {
32
+ const resolved = LEGACY_MAP[name] || name;
33
+
34
+ if (!VALID_HOOKS.includes(resolved)) {
35
+ throw new Error(`[ProcessingPipeline] Unknown hook: "${name}". Valid hooks: ${VALID_HOOKS.join(', ')}`);
36
+ }
37
+ if (typeof fn !== 'function') {
38
+ throw new Error(`[ProcessingPipeline] Hook "${name}" must be a function`);
39
+ }
40
+
41
+ this._hooks[resolved].push(fn);
42
+ this._hookMeta.set(fn, { originalName: name });
43
+ return this;
44
+ }
45
+
46
+ async run(context, executeFn, shouldContinue = () => true) {
47
+ const ctx = { metadata: {}, ...context };
48
+
49
+ try {
50
+ const preProcessResult = await this._runPreProcessHooks(ctx);
51
+ if (preProcessResult.skip) {
52
+ logger.debug('[ProcessingPipeline] Skipped by preProcess hook', { chatId: ctx.chatId, type: ctx.type });
53
+ this._runPostProcessHooks(ctx, null, null);
54
+ return null;
55
+ }
56
+
57
+ if (!shouldContinue()) return null;
58
+
59
+ const result = await executeFn(preProcessResult, shouldContinue);
60
+
61
+ if (!shouldContinue()) return null;
62
+
63
+ this._runPostProcessHooks(ctx, result, null);
64
+ return result;
65
+ } catch (error) {
66
+ logger.error('[ProcessingPipeline] Error during processing', { chatId: ctx.chatId, type: ctx.type, error: error.message });
67
+ this._runPostProcessHooks(ctx, null, error);
68
+ throw error;
69
+ }
70
+ }
71
+
72
+ async _runPreProcessHooks(context) {
73
+ const hooks = this._hooks.preProcess;
74
+ const merged = {
75
+ skip: false,
76
+ additionalInstructions: '',
77
+ additionalMessages: [],
78
+ toolChoice: null,
79
+ metadata: context.metadata || {},
80
+ };
81
+
82
+ if (hooks.length === 0) return merged;
83
+
84
+ for (const fn of hooks) {
85
+ const result = await fn(context);
86
+ if (!result) continue;
87
+
88
+ if (result.skip) {
89
+ merged.skip = true;
90
+ return merged;
91
+ }
92
+
93
+ if (result.additionalInstructions) {
94
+ merged.additionalInstructions = merged.additionalInstructions
95
+ ? `${merged.additionalInstructions}\n\n${result.additionalInstructions}`
96
+ : result.additionalInstructions;
97
+ }
98
+ if (Array.isArray(result.additionalMessages) && result.additionalMessages.length > 0) {
99
+ merged.additionalMessages.push(...result.additionalMessages);
100
+ }
101
+ if (result.toolChoice) {
102
+ merged.toolChoice = result.toolChoice;
103
+ }
104
+ if (result.metadata && typeof result.metadata === 'object') {
105
+ Object.assign(context.metadata, result.metadata);
106
+ merged.metadata = context.metadata;
107
+ }
108
+ }
109
+
110
+ return merged;
111
+ }
112
+
113
+ _runPostProcessHooks(context, result, error) {
114
+ const hooks = this._hooks.postProcess;
115
+ if (hooks.length === 0) return;
116
+
117
+ setImmediate(() => {
118
+ const postContext = { chatId: context.chatId, type: context.type };
119
+ if (result) postContext.result = result;
120
+ if (error) postContext.error = error;
121
+
122
+ // onBatchError: error only. postBatch: always (original used finally block).
123
+ // postProcess and untagged: always.
124
+ const applicable = hooks.filter(fn => {
125
+ const meta = this._hookMeta.get(fn);
126
+ if (meta?.originalName === 'onBatchError') return !!error;
127
+ return true;
128
+ });
129
+
130
+ Promise.allSettled(applicable.map(fn => fn(postContext)))
131
+ .then(results => {
132
+ for (const r of results) {
133
+ if (r.status === 'rejected') {
134
+ logger.error('[ProcessingPipeline] postProcess hook failed', {
135
+ chatId: context.chatId,
136
+ error: r.reason?.message
137
+ });
138
+ }
139
+ }
140
+ });
141
+ });
142
+ }
143
+ }
144
+
145
+ module.exports = { ProcessingPipeline };
package/lib/index.d.ts CHANGED
@@ -244,6 +244,7 @@ declare module '@peopl-health/nexus' {
244
244
  processIncomingMessage(messageData: MessageData): Promise<any>;
245
245
  getEventBus(): import('events').EventEmitter;
246
246
  getBatchingManager(): BatchingManager;
247
+ getPipeline(): ProcessingPipeline;
247
248
  getAssistantProcessor(): AssistantProcessor;
248
249
  isConnected(): boolean;
249
250
  disconnect(): Promise<void>;
@@ -495,12 +496,13 @@ declare module '@peopl-health/nexus' {
495
496
  export function getPatientMemory(): any;
496
497
  export function getConversationSummary(): any;
497
498
 
498
- // BatchingManager
499
+ // BatchingManager — per-chatId concurrency control
499
500
  export interface BatchingConfig {
500
501
  enabled?: boolean;
501
502
  abortOnNewMessage?: boolean;
502
503
  immediateRestart?: boolean;
503
504
  typingIndicator?: boolean;
505
+ hooks?: Record<string, Function | Function[]>;
504
506
  }
505
507
 
506
508
  export class BatchingManager {
@@ -510,11 +512,43 @@ declare module '@peopl-health/nexus' {
510
512
  });
511
513
  setProvider(provider: MessageProvider): void;
512
514
  isProcessing(chatId: string): boolean;
515
+ isActiveRun(chatId: string, runId: string): boolean;
513
516
  handleBatchedProcessing(
514
517
  chatId: string,
515
518
  processingFn: (runId: string) => Promise<any>,
516
519
  sendResponseFn: (result: any) => Promise<void>
517
520
  ): Promise<void>;
521
+ enqueueProcessing(
522
+ chatId: string,
523
+ processingFn: (runId: string) => Promise<any>,
524
+ sendResponseFn: (result: any) => Promise<void>
525
+ ): Promise<void>;
526
+ }
527
+
528
+ // ProcessingPipeline — hook lifecycle for all LLM operations
529
+ export interface PreProcessResult {
530
+ skip?: boolean;
531
+ additionalInstructions?: string;
532
+ additionalMessages?: Array<Record<string, any>>;
533
+ toolChoice?: Record<string, any> | null;
534
+ metadata?: Record<string, any>;
535
+ }
536
+
537
+ export interface ProcessingContext {
538
+ chatId: string;
539
+ runId: string;
540
+ type: string;
541
+ metadata?: Record<string, any>;
542
+ }
543
+
544
+ export class ProcessingPipeline {
545
+ constructor(hooks?: Record<string, Function | Function[]>);
546
+ addHook(name: string, fn: Function): this;
547
+ run(
548
+ context: ProcessingContext,
549
+ executeFn: (preProcessResult: PreProcessResult, shouldContinue: () => boolean) => Promise<any>,
550
+ shouldContinue?: () => boolean
551
+ ): Promise<any>;
518
552
  }
519
553
 
520
554
  // AssistantProcessor
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "3.12.0",
3
+ "version": "3.13.1",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",