@peopl-health/nexus 3.12.0 → 3.13.0

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,6 @@ 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;
50
18
  }
51
19
 
52
20
  setProvider(provider) {
@@ -70,28 +38,22 @@ class BatchingManager {
70
38
 
71
39
  if (this.config.immediateRestart) {
72
40
  this._clearProcessingState(chatId);
73
- await this._processWithLock(chatId, processingFn, sendResponseFn);
41
+ await this._runWithLock(chatId, processingFn, sendResponseFn);
74
42
  }
75
43
  }
76
44
  return;
77
45
  }
78
46
 
79
47
  this.processingLocks.set(chatId, true);
80
- await this._processWithLock(chatId, processingFn, sendResponseFn);
48
+ await this._runWithLock(chatId, processingFn, sendResponseFn);
81
49
  }
82
50
 
83
- async _processWithLock(chatId, processingFn, sendResponseFn) {
51
+ async _runWithLock(chatId, processingFn, sendResponseFn) {
84
52
  this.processingLocks.set(chatId, true);
85
53
  const runId = `run_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
86
54
  this.activeRequests.set(chatId, runId);
87
55
 
88
56
  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
57
  await this._startTypingRefresh(chatId, runId);
96
58
  if (this._checkAbandoned(runId)) return;
97
59
 
@@ -101,11 +63,9 @@ class BatchingManager {
101
63
  if (sendResponseFn && result) await sendResponseFn(result);
102
64
  } catch (error) {
103
65
  logger.error('[BatchingManager] Error processing messages', { chatId, error: error.message });
104
- this._runDeferredHooks('onBatchError', chatId, error);
105
66
  } finally {
106
67
  if (this.activeRequests.get(chatId) === runId) {
107
68
  this._clearProcessingState(chatId);
108
- this._runDeferredHooks('postBatch', chatId);
109
69
  }
110
70
  if (this.abandonedRuns.size > 100) {
111
71
  const toKeep = [...this.abandonedRuns].slice(-20);
@@ -121,63 +81,6 @@ class BatchingManager {
121
81
  this._stopTyping(chatId);
122
82
  }
123
83
 
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
84
  _stopTyping(chatId) {
182
85
  const interval = this.typingIntervals.get(chatId);
183
86
  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,6 +512,7 @@ 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>,
@@ -517,6 +520,32 @@ declare module '@peopl-health/nexus' {
517
520
  ): Promise<void>;
518
521
  }
519
522
 
523
+ // ProcessingPipeline — hook lifecycle for all LLM operations
524
+ export interface PreProcessResult {
525
+ skip?: boolean;
526
+ additionalInstructions?: string;
527
+ additionalMessages?: Array<Record<string, any>>;
528
+ toolChoice?: Record<string, any> | null;
529
+ metadata?: Record<string, any>;
530
+ }
531
+
532
+ export interface ProcessingContext {
533
+ chatId: string;
534
+ runId: string;
535
+ type: string;
536
+ metadata?: Record<string, any>;
537
+ }
538
+
539
+ export class ProcessingPipeline {
540
+ constructor(hooks?: Record<string, Function | Function[]>);
541
+ addHook(name: string, fn: Function): this;
542
+ run(
543
+ context: ProcessingContext,
544
+ executeFn: (preProcessResult: PreProcessResult, shouldContinue: () => boolean) => Promise<any>,
545
+ shouldContinue?: () => boolean
546
+ ): Promise<any>;
547
+ }
548
+
520
549
  // AssistantProcessor
521
550
  export interface AssistantProcessorConfig {
522
551
  mode?: 'local' | 'queue';
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.0",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",