fluxy-bot 0.15.1 → 0.15.5

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fluxy-bot",
3
- "version": "0.15.1",
3
+ "version": "0.15.5",
4
4
  "releaseNotes": [
5
5
  "1. react router implemented",
6
6
  "2. new workspace design",
package/shared/config.ts CHANGED
@@ -3,10 +3,12 @@ import { paths, DATA_DIR } from './paths.js';
3
3
 
4
4
  export interface ChannelConfig {
5
5
  enabled: boolean;
6
- /** 'shared' = user's own number, 'dedicated' = Fluxy has its own number */
7
- mode: 'shared' | 'dedicated';
8
- /** The human/owner's phone number (used for role resolution in dedicated mode) */
9
- humanPhone?: string;
6
+ /** 'channel' = just talk to me (self-chat only), 'business' = admin/customer mode */
7
+ mode: 'channel' | 'business';
8
+ /** Phone numbers with admin access (owner, secretary, etc.) business mode only */
9
+ admins?: string[];
10
+ /** Active skill for customer-facing mode (folder name in workspace/skills/) */
11
+ skill?: string;
10
12
  }
11
13
 
12
14
  export interface BotConfig {
@@ -3,10 +3,16 @@
3
3
  *
4
4
  * Responsibilities:
5
5
  * - Manages channel providers (WhatsApp, Telegram, etc.)
6
- * - Resolves sender role (human vs customer)
6
+ * - Resolves sender role (admin vs customer) based on mode
7
7
  * - Routes inbound messages to the agent with appropriate system prompt
8
- * - Routes outbound messages from agent/API back to channels
9
- * - Manages parallel agent instances for customer conversations
8
+ * - Routes agent responses back to channels
9
+ * - Manages parallel agent instances for customer conversations (business mode)
10
+ *
11
+ * Modes:
12
+ * - channel: Just talk to me. Only self-chat (fromMe=true) triggers the agent.
13
+ * All other messages are ignored — it's the user's personal WhatsApp.
14
+ * - business: Fluxy has its own number. Numbers in the admins array get the main
15
+ * system prompt. Everyone else gets the customer support prompt.
10
16
  */
11
17
 
12
18
  import fs from 'fs';
@@ -19,6 +25,7 @@ import { WhatsAppChannel } from './whatsapp.js';
19
25
  import type { ChannelConfig, ChannelProvider, ChannelStatus, ChannelType, InboundMessage, SenderRole } from './types.js';
20
26
 
21
27
  const MAX_CONCURRENT_AGENTS = 5;
28
+ const MAX_BUFFER_MESSAGES = 30;
22
29
 
23
30
  interface ChannelManagerOpts {
24
31
  broadcastFluxy: (type: string, data: any) => void;
@@ -30,7 +37,11 @@ interface ChannelManagerOpts {
30
37
  interface ActiveAgentQuery {
31
38
  sender: string;
32
39
  channel: ChannelType;
33
- abortController?: AbortController;
40
+ }
41
+
42
+ interface BufferedMessage {
43
+ role: 'user' | 'assistant';
44
+ content: string;
34
45
  }
35
46
 
36
47
  export class ChannelManager {
@@ -39,6 +50,8 @@ export class ChannelManager {
39
50
  private activeAgents = new Map<string, ActiveAgentQuery>();
40
51
  private messageQueue: InboundMessage[] = [];
41
52
  private statusListeners: ((status: ChannelStatus) => void)[] = [];
53
+ /** In-memory conversation history per customer (keyed by "channel:phone") */
54
+ private customerBuffers = new Map<string, BufferedMessage[]>();
42
55
 
43
56
  constructor(opts: ChannelManagerOpts) {
44
57
  this.opts = opts;
@@ -47,7 +60,7 @@ export class ChannelManager {
47
60
  /** Initialize channels based on config */
48
61
  async init(): Promise<void> {
49
62
  const config = loadConfig();
50
- const channelConfigs = (config as any).channels as Record<string, ChannelConfig> | undefined;
63
+ const channelConfigs = config.channels;
51
64
 
52
65
  if (!channelConfigs?.whatsapp?.enabled) {
53
66
  log.info('[channels] WhatsApp not enabled — skipping');
@@ -56,7 +69,7 @@ export class ChannelManager {
56
69
 
57
70
  log.info('[channels] Initializing WhatsApp channel...');
58
71
  const whatsapp = new WhatsAppChannel(
59
- (sender, senderName, text, fromMe) => this.handleInboundMessage('whatsapp', sender, senderName, text, fromMe),
72
+ (sender, senderName, text, fromMe, isSelfChat) => this.handleInboundMessage('whatsapp', sender, senderName, text, fromMe, isSelfChat),
60
73
  (status) => this.handleStatusChange(status),
61
74
  );
62
75
  this.providers.set('whatsapp', whatsapp);
@@ -75,9 +88,8 @@ export class ChannelManager {
75
88
  async connectWhatsApp(): Promise<void> {
76
89
  let provider = this.providers.get('whatsapp');
77
90
  if (!provider) {
78
- // Create provider on-demand if not initialized
79
91
  const whatsapp = new WhatsAppChannel(
80
- (sender, senderName, text, fromMe) => this.handleInboundMessage('whatsapp', sender, senderName, text, fromMe),
92
+ (sender, senderName, text, fromMe, isSelfChat) => this.handleInboundMessage('whatsapp', sender, senderName, text, fromMe, isSelfChat),
81
93
  (status) => this.handleStatusChange(status),
82
94
  );
83
95
  this.providers.set('whatsapp', whatsapp);
@@ -148,27 +160,10 @@ export class ChannelManager {
148
160
  }
149
161
  }
150
162
 
151
- /** Resolve sender role based on channel config */
152
- private resolveRole(channel: ChannelType, sender: string, fromMe: boolean): SenderRole {
163
+ /** Get the channel config, re-reading from disk each time */
164
+ private getChannelConfig(channel: ChannelType): ChannelConfig | undefined {
153
165
  const config = loadConfig();
154
- const channelConfigs = (config as any).channels as Record<string, ChannelConfig> | undefined;
155
- const channelConfig = channelConfigs?.[channel];
156
-
157
- if (!channelConfig) return 'customer';
158
-
159
- if (channelConfig.mode === 'shared') {
160
- // Shared number mode: fromMe messages are from the human
161
- return fromMe ? 'human' : 'customer';
162
- }
163
-
164
- // Dedicated number mode: check if sender matches human's phone
165
- if (channelConfig.humanPhone) {
166
- const senderPhone = sender.replace(/@.*/, '').replace(/[^0-9]/g, '');
167
- const humanPhone = channelConfig.humanPhone.replace(/[^0-9]/g, '');
168
- if (senderPhone === humanPhone) return 'human';
169
- }
170
-
171
- return 'customer';
166
+ return config.channels?.[channel];
172
167
  }
173
168
 
174
169
  /** Handle an incoming message from any channel */
@@ -178,8 +173,44 @@ export class ChannelManager {
178
173
  senderName: string | undefined,
179
174
  text: string,
180
175
  fromMe: boolean,
176
+ isSelfChat: boolean,
181
177
  ) {
182
- const role = this.resolveRole(channel, sender, fromMe);
178
+ const channelConfig = this.getChannelConfig(channel);
179
+ if (!channelConfig) return;
180
+
181
+ const mode = channelConfig.mode || 'channel';
182
+
183
+ // ── Channel mode: ONLY respond to self-chat ──
184
+ if (mode === 'channel') {
185
+ if (!fromMe || !isSelfChat) {
186
+ // Ignore everything except self-chat messages
187
+ return;
188
+ }
189
+
190
+ const message: InboundMessage = {
191
+ channel,
192
+ sender: sender.replace(/@.*/, ''),
193
+ senderName,
194
+ role: 'admin',
195
+ text,
196
+ rawSender: sender,
197
+ };
198
+
199
+ log.info(`[channels] Channel mode | self-chat | "${text.slice(0, 60)}"`);
200
+ await this.handleAdminMessage(message);
201
+ return;
202
+ }
203
+
204
+ // ── Business mode: only respond to INCOMING messages (fromMe=false) ──
205
+ // fromMe=true means either:
206
+ // - Fluxy's own sent replies (would cause loops)
207
+ // - User typing on Fluxy's WhatsApp Web (not intended for the bot)
208
+ // Both should be ignored.
209
+ if (fromMe) {
210
+ return;
211
+ }
212
+
213
+ const role = this.resolveBusinessRole(channelConfig, sender);
183
214
 
184
215
  const message: InboundMessage = {
185
216
  channel,
@@ -190,31 +221,43 @@ export class ChannelManager {
190
221
  rawSender: sender,
191
222
  };
192
223
 
193
- log.info(`[channels] Inbound ${channel} | ${message.sender} | role=${role} | "${text.slice(0, 60)}"`);
224
+ log.info(`[channels] Business mode | ${message.sender} | role=${role} | "${text.slice(0, 60)}"`);
194
225
 
195
- if (role === 'human') {
196
- await this.handleHumanMessage(message);
226
+ if (role === 'admin') {
227
+ await this.handleAdminMessage(message);
197
228
  } else {
198
- await this.handleCustomerMessage(message);
229
+ await this.handleCustomerMessage(message, channelConfig);
199
230
  }
200
231
  }
201
232
 
202
- /** Handle message from the human/ownermirrors to chat conversation */
203
- private async handleHumanMessage(msg: InboundMessage) {
233
+ /** Resolve role in business modecheck admins array */
234
+ private resolveBusinessRole(config: ChannelConfig, sender: string): SenderRole {
235
+ if (config.admins?.length) {
236
+ const senderPhone = sender.replace(/@.*/, '').replace(/[^0-9]/g, '');
237
+ for (const admin of config.admins) {
238
+ const adminPhone = admin.replace(/[^0-9]/g, '');
239
+ if (senderPhone === adminPhone || senderPhone.endsWith(adminPhone) || adminPhone.endsWith(senderPhone)) {
240
+ return 'admin';
241
+ }
242
+ }
243
+ }
244
+
245
+ return 'customer';
246
+ }
247
+
248
+ /** Handle message from an admin — mirrors to chat conversation, uses main system prompt */
249
+ private async handleAdminMessage(msg: InboundMessage) {
204
250
  const { workerApi, broadcastFluxy, getModel } = this.opts;
205
251
  const model = getModel();
206
252
 
207
- // Get or create the human's WhatsApp conversation (mirrored with chat)
253
+ // Get or create conversation (shared with chat for mirroring)
208
254
  let convId: string | undefined;
209
255
  try {
210
256
  const ctx = await workerApi('/api/context/current');
211
257
  if (ctx.conversationId) {
212
258
  convId = ctx.conversationId;
213
259
  } else {
214
- const conv = await workerApi('/api/conversations', 'POST', {
215
- title: `WhatsApp`,
216
- model,
217
- });
260
+ const conv = await workerApi('/api/conversations', 'POST', { title: 'WhatsApp', model });
218
261
  convId = conv.id;
219
262
  await workerApi('/api/context/set', 'POST', { conversationId: convId });
220
263
  }
@@ -240,7 +283,7 @@ export class ChannelManager {
240
283
  message: { role: 'user', content: msg.text, timestamp: new Date().toISOString() },
241
284
  });
242
285
 
243
- // Fetch agent/user names and recent messages
286
+ // Fetch names and recent messages
244
287
  let botName = 'Fluxy', humanName = 'Human';
245
288
  let recentMessages: RecentMessage[] = [];
246
289
  try {
@@ -261,8 +304,8 @@ export class ChannelManager {
261
304
  }
262
305
  } catch {}
263
306
 
264
- // Run agent with main system prompt (same as chat)
265
- const channelContext = `[WhatsApp | ${msg.sender} | human]\n`;
307
+ // Channel context — tells the agent this is a WhatsApp message, respond naturally
308
+ const channelContext = `[WhatsApp | ${msg.sender} | admin]\n`;
266
309
 
267
310
  startFluxyAgentQuery(
268
311
  convId,
@@ -270,7 +313,7 @@ export class ChannelManager {
270
313
  model,
271
314
  (type, eventData) => {
272
315
  if (type === 'bot:response' && eventData.content) {
273
- // Send response back via WhatsApp
316
+ // Send agent's response back via WhatsApp
274
317
  this.sendMessage(msg.channel, msg.rawSender, eventData.content).catch((err) => {
275
318
  log.warn(`[channels] Failed to send WhatsApp reply: ${err.message}`);
276
319
  });
@@ -299,13 +342,12 @@ export class ChannelManager {
299
342
  );
300
343
  }
301
344
 
302
- /** Handle message from a customer — runs support agent in parallel */
303
- private async handleCustomerMessage(msg: InboundMessage) {
345
+ /** Handle message from a customer — runs support agent in parallel with conversation context */
346
+ private async handleCustomerMessage(msg: InboundMessage, channelConfig: ChannelConfig) {
304
347
  const agentKey = `${msg.channel}:${msg.sender}`;
305
348
 
306
349
  // Check concurrent limit
307
350
  if (this.activeAgents.size >= MAX_CONCURRENT_AGENTS && !this.activeAgents.has(agentKey)) {
308
- // Queue the message
309
351
  log.info(`[channels] Max concurrent agents reached — queuing message from ${msg.sender}`);
310
352
  this.messageQueue.push(msg);
311
353
  return;
@@ -314,10 +356,10 @@ export class ChannelManager {
314
356
  const { workerApi, getModel } = this.opts;
315
357
  const model = getModel();
316
358
 
317
- // Load support system prompt from skill
318
- const supportPrompt = this.loadSupportPrompt();
359
+ // Load the active skill's SCRIPT.md as the customer-facing system prompt
360
+ const scriptPrompt = this.loadActiveScript(channelConfig);
319
361
 
320
- // Fetch agent/user names
362
+ // Fetch agent name
321
363
  let botName = 'Fluxy', humanName = 'Human';
322
364
  try {
323
365
  const status = await workerApi('/api/onboard/status');
@@ -325,19 +367,60 @@ export class ChannelManager {
325
367
  humanName = status.userName || 'Human';
326
368
  } catch {}
327
369
 
328
- // Build channel context
370
+ // Get or create conversation buffer for this customer
371
+ let buffer = this.customerBuffers.get(agentKey);
372
+ if (!buffer) {
373
+ buffer = [];
374
+ this.customerBuffers.set(agentKey, buffer);
375
+ }
376
+
377
+ // Add the new user message to the buffer
378
+ buffer.push({ role: 'user', content: msg.text });
379
+
380
+ // Trim buffer to max size
381
+ if (buffer.length > MAX_BUFFER_MESSAGES) {
382
+ buffer.splice(0, buffer.length - MAX_BUFFER_MESSAGES);
383
+ }
384
+
385
+ // Build recent messages for context (everything except the last one, which is the current message)
386
+ const recentMessages: RecentMessage[] = buffer.length > 1
387
+ ? buffer.slice(0, -1).map((m) => ({ role: m.role, content: m.content }))
388
+ : [];
389
+
390
+ // Also load long-term memory from whatsapp/{phone}.md if it exists
391
+ let customerMemory = '';
392
+ try {
393
+ const memoryPath = path.join(WORKSPACE_DIR, 'whatsapp', `${msg.sender}.md`);
394
+ if (fs.existsSync(memoryPath)) {
395
+ customerMemory = fs.readFileSync(memoryPath, 'utf-8').trim();
396
+ }
397
+ } catch {}
398
+
329
399
  const channelContext = `[WhatsApp | ${msg.sender} | customer${msg.senderName ? ` | ${msg.senderName}` : ''}]\n`;
330
- const convId = `channel-${agentKey}-${Date.now()}`;
400
+
401
+ // Stable convId per customer (not per message)
402
+ const convId = `channel-${agentKey}`;
331
403
 
332
404
  this.activeAgents.set(agentKey, { sender: msg.sender, channel: msg.channel });
333
405
 
406
+ // Build an enriched script prompt with customer memory if available
407
+ let enrichedScript = scriptPrompt;
408
+ if (customerMemory && enrichedScript) {
409
+ enrichedScript += `\n\n---\n# Customer History (${msg.sender})\n\n${customerMemory}`;
410
+ }
411
+
334
412
  startFluxyAgentQuery(
335
413
  convId,
336
414
  channelContext + msg.text,
337
415
  model,
338
416
  (type, eventData) => {
339
417
  if (type === 'bot:response' && eventData.content) {
340
- // Send response back via WhatsApp
418
+ // Add assistant response to the buffer
419
+ buffer!.push({ role: 'assistant', content: eventData.content });
420
+ if (buffer!.length > MAX_BUFFER_MESSAGES) {
421
+ buffer!.splice(0, buffer!.length - MAX_BUFFER_MESSAGES);
422
+ }
423
+
341
424
  this.sendMessage(msg.channel, msg.rawSender, eventData.content).catch((err) => {
342
425
  log.warn(`[channels] Failed to send customer reply: ${err.message}`);
343
426
  });
@@ -345,40 +428,39 @@ export class ChannelManager {
345
428
 
346
429
  if (type === 'bot:done') {
347
430
  this.activeAgents.delete(agentKey);
348
-
349
- if (eventData.usedFileTools) {
350
- this.opts.restartBackend();
351
- }
352
-
353
- // Process queued messages
431
+ if (eventData.usedFileTools) this.opts.restartBackend();
354
432
  this.processQueue();
355
433
  }
356
434
  },
357
435
  undefined,
358
436
  undefined,
359
437
  { botName, humanName },
360
- undefined,
361
- supportPrompt,
438
+ recentMessages,
439
+ enrichedScript,
362
440
  );
363
441
  }
364
442
 
365
- /** Load customer-facing system prompt from skills */
366
- private loadSupportPrompt(): string | undefined {
367
- // Look for SUPPORT.md in any skill directory
368
- const skillsDir = path.join(WORKSPACE_DIR, 'skills');
443
+ /** Load SCRIPT.md from the active skill configured for this channel */
444
+ private loadActiveScript(channelConfig: ChannelConfig): string | undefined {
445
+ const skillName = channelConfig.skill;
446
+ if (!skillName) {
447
+ log.warn('[channels] No active skill configured — customer will get no script');
448
+ return undefined;
449
+ }
450
+
451
+ const scriptPath = path.join(WORKSPACE_DIR, 'skills', skillName, 'SCRIPT.md');
369
452
  try {
370
- for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
371
- if (!entry.isDirectory()) continue;
372
- const supportPath = path.join(skillsDir, entry.name, 'SUPPORT.md');
373
- if (fs.existsSync(supportPath)) {
374
- const content = fs.readFileSync(supportPath, 'utf-8').trim();
375
- if (content) {
376
- log.info(`[channels] Loaded support prompt from skill: ${entry.name}`);
377
- return content;
378
- }
453
+ if (fs.existsSync(scriptPath)) {
454
+ const content = fs.readFileSync(scriptPath, 'utf-8').trim();
455
+ if (content) {
456
+ log.info(`[channels] Loaded SCRIPT.md from skill: ${skillName}`);
457
+ return content;
379
458
  }
380
459
  }
381
- } catch {}
460
+ log.warn(`[channels] SCRIPT.md not found in skill: ${skillName}`);
461
+ } catch (err: any) {
462
+ log.warn(`[channels] Failed to load SCRIPT.md from ${skillName}: ${err.message}`);
463
+ }
382
464
  return undefined;
383
465
  }
384
466
 
@@ -386,8 +468,10 @@ export class ChannelManager {
386
468
  private processQueue() {
387
469
  while (this.messageQueue.length > 0 && this.activeAgents.size < MAX_CONCURRENT_AGENTS) {
388
470
  const queued = this.messageQueue.shift()!;
471
+ const config = this.getChannelConfig(queued.channel);
472
+ if (!config) continue;
389
473
  log.info(`[channels] Processing queued message from ${queued.sender}`);
390
- this.handleCustomerMessage(queued);
474
+ this.handleCustomerMessage(queued, config);
391
475
  }
392
476
  }
393
477
  }
@@ -3,19 +3,21 @@
3
3
  */
4
4
 
5
5
  export type ChannelType = 'whatsapp' | 'telegram';
6
- export type SenderRole = 'human' | 'customer';
6
+ export type SenderRole = 'admin' | 'customer';
7
7
 
8
8
  export interface ChannelConfig {
9
9
  enabled: boolean;
10
- /** 'shared' = user's own number, 'dedicated' = Fluxy has its own number */
11
- mode: 'shared' | 'dedicated';
12
- /** The human/owner's phone number (used for role resolution in dedicated mode) */
13
- humanPhone?: string;
10
+ /** 'channel' = just talk to me (self-chat only), 'business' = admin/customer mode */
11
+ mode: 'channel' | 'business';
12
+ /** Phone numbers with admin access (owner, secretary, etc.) business mode only */
13
+ admins?: string[];
14
+ /** Active skill for customer-facing mode (folder name in workspace/skills/) */
15
+ skill?: string;
14
16
  }
15
17
 
16
18
  export interface InboundMessage {
17
19
  channel: ChannelType;
18
- /** Sender identifier (phone number / JID) */
20
+ /** Sender identifier (phone number) */
19
21
  sender: string;
20
22
  /** Sender display name if available */
21
23
  senderName?: string;
@@ -23,7 +25,7 @@ export interface InboundMessage {
23
25
  role: SenderRole;
24
26
  /** Message text content */
25
27
  text: string;
26
- /** Raw sender JID (channel-specific format) */
28
+ /** Raw sender JID (channel-specific format, used for replies) */
27
29
  rawSender: string;
28
30
  }
29
31
 
@@ -23,20 +23,25 @@ import type { ChannelProvider, ChannelStatus, ChannelType } from './types.js';
23
23
  const AUTH_DIR = path.join(DATA_DIR, 'channels', 'whatsapp', 'auth');
24
24
 
25
25
  /** Callback when a new message arrives */
26
- export type OnWhatsAppMessage = (sender: string, senderName: string | undefined, text: string, fromMe: boolean) => void;
26
+ export type OnWhatsAppMessage = (sender: string, senderName: string | undefined, text: string, fromMe: boolean, isSelfChat: boolean) => void;
27
27
 
28
28
  export class WhatsAppChannel implements ChannelProvider {
29
29
  readonly type: ChannelType = 'whatsapp';
30
30
 
31
31
  private sock: WASocket | null = null;
32
32
  private connected = false;
33
- private qrData: string | null = null; // raw QR string data
34
- private qrSvg: string | null = null; // SVG rendered QR
33
+ private qrData: string | null = null;
34
+ private qrSvg: string | null = null;
35
35
  private onMessage: OnWhatsAppMessage;
36
36
  private onStatusChange: (status: ChannelStatus) => void;
37
37
  private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
38
38
  private intentionalDisconnect = false;
39
39
 
40
+ /** Maps LID JIDs to phone JIDs (WhatsApp uses LIDs internally for self-chat) */
41
+ private lidToPhoneMap = new Map<string, string>();
42
+ /** Our own phone JID (number@s.whatsapp.net) */
43
+ private ownPhoneJid: string | null = null;
44
+
40
45
  constructor(
41
46
  onMessage: OnWhatsAppMessage,
42
47
  onStatusChange: (status: ChannelStatus) => void,
@@ -110,6 +115,42 @@ export class WhatsAppChannel implements ChannelProvider {
110
115
 
111
116
  // ── Internal ──
112
117
 
118
+ /** Translate a JID from LID format to phone format if possible */
119
+ private translateJid(jid: string): string {
120
+ // If it's already a phone JID, return as-is
121
+ if (jid.endsWith('@s.whatsapp.net')) return jid;
122
+
123
+ // Check LID map
124
+ const mapped = this.lidToPhoneMap.get(jid);
125
+ if (mapped) return mapped;
126
+
127
+ // If it's a LID JID and we know our own phone, and this looks like a self-chat LID
128
+ // LID JIDs typically end with @lid or have a long numeric format
129
+ if (this.ownPhoneJid && (jid.includes('@lid') || jid.match(/^\d{15,}@/))) {
130
+ log.info(`[whatsapp] Unmapped LID ${jid} — assuming self-chat, using own phone JID`);
131
+ this.lidToPhoneMap.set(jid, this.ownPhoneJid);
132
+ return this.ownPhoneJid;
133
+ }
134
+
135
+ return jid;
136
+ }
137
+
138
+ /** Build the LID-to-phone mapping from sock.user */
139
+ private buildLidMap() {
140
+ if (!this.sock?.user) return;
141
+
142
+ const user = this.sock.user;
143
+ // user.id is "phone:device@s.whatsapp.net" — extract phone
144
+ const phone = user.id.split(':')[0];
145
+ this.ownPhoneJid = `${phone}@s.whatsapp.net`;
146
+
147
+ // user.lid (if available) is the LID JID
148
+ if ((user as any).lid) {
149
+ this.lidToPhoneMap.set((user as any).lid, this.ownPhoneJid);
150
+ log.info(`[whatsapp] LID map: ${(user as any).lid} → ${this.ownPhoneJid}`);
151
+ }
152
+ }
153
+
113
154
  private async connectInternal(): Promise<void> {
114
155
  // Ensure auth directory exists
115
156
  fs.mkdirSync(AUTH_DIR, { recursive: true });
@@ -164,6 +205,7 @@ export class WhatsAppChannel implements ChannelProvider {
164
205
  this.connected = true;
165
206
  this.qrData = null;
166
207
  this.qrSvg = null;
208
+ this.buildLidMap();
167
209
  log.ok(`[whatsapp] Connected as ${sock.user?.id}`);
168
210
  this.emitStatus();
169
211
  }
@@ -207,12 +249,18 @@ export class WhatsAppChannel implements ChannelProvider {
207
249
  if (!text) continue;
208
250
 
209
251
  const fromMe = msg.key.fromMe || false;
210
- const sender = msg.key.remoteJid || '';
252
+ const rawSender = msg.key.remoteJid || '';
253
+
254
+ // Translate LID JIDs to phone JIDs
255
+ const sender = this.translateJid(rawSender);
211
256
  const pushName = msg.pushName || undefined;
212
257
 
213
- log.info(`[whatsapp] Message from ${sender} (fromMe=${fromMe}): ${text.slice(0, 80)}`);
258
+ // Detect self-chat: remoteJid matches our own phone number
259
+ const isSelfChat = this.ownPhoneJid !== null && sender === this.ownPhoneJid;
260
+
261
+ log.info(`[whatsapp] Message from ${sender} (raw=${rawSender}, fromMe=${fromMe}, selfChat=${isSelfChat}): ${text.slice(0, 80)}`);
214
262
 
215
- this.onMessage(sender, pushName, text, fromMe);
263
+ this.onMessage(sender, pushName, text, fromMe, isSelfChat);
216
264
  }
217
265
  });
218
266
  }
@@ -7,7 +7,7 @@ import { query, type SDKMessage, type SDKUserMessage } from '@anthropic-ai/claud
7
7
  import fs from 'fs';
8
8
  import path from 'path';
9
9
  import { log } from '../shared/logger.js';
10
- import { PKG_DIR, WORKSPACE_DIR } from '../shared/paths.js';
10
+ import { WORKSPACE_DIR } from '../shared/paths.js';
11
11
  import type { SavedFile } from './file-saver.js';
12
12
  import { getClaudeAccessToken } from '../worker/claude-auth.js';
13
13
 
@@ -178,29 +178,23 @@ export async function startFluxyAgentQuery(
178
178
  const sdkPrompt: string | AsyncIterable<SDKUserMessage> =
179
179
  attachments?.length ? buildMultiPartPrompt(prompt, attachments, savedFiles) : plainPrompt;
180
180
 
181
+ // Auto-discover skills — inject SKILL.md contents into system prompt (no SDK plugin system needed)
181
182
  try {
182
- // Auto-discover all skill plugins in workspace/skills/ — any folder with a valid plugin.json is loaded
183
- // Skills use a flat structure on disk (SKILL.md at the root), but the SDK expects
184
- // skills/{name}/SKILL.md — we bridge the gap with symlinks created on discovery.
185
- const skillsDir = path.join(PKG_DIR, 'workspace', 'skills');
186
- const plugins: { type: 'local'; path: string }[] = [];
183
+ const skillsDir = path.join(WORKSPACE_DIR, 'skills');
184
+ const skillContents: string[] = [];
187
185
  try {
188
186
  for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
189
- if (entry.isDirectory() && fs.existsSync(path.join(skillsDir, entry.name, '.claude-plugin', 'plugin.json'))) {
190
- plugins.push({ type: 'local' as const, path: path.join(skillsDir, entry.name) });
191
-
192
- // Bridge flat SKILL.md → nested path the SDK expects (via symlink)
193
- const skillName = entry.name;
194
- const flatSkillMd = path.join(skillsDir, skillName, 'SKILL.md');
195
- const sdkDir = path.join(skillsDir, skillName, 'skills', skillName);
196
- const sdkSkillMd = path.join(sdkDir, 'SKILL.md');
197
- if (fs.existsSync(flatSkillMd) && !fs.existsSync(sdkSkillMd)) {
198
- fs.mkdirSync(sdkDir, { recursive: true });
199
- fs.symlinkSync(flatSkillMd, sdkSkillMd);
200
- }
187
+ if (!entry.isDirectory()) continue;
188
+ const skillMd = path.join(skillsDir, entry.name, 'SKILL.md');
189
+ if (fs.existsSync(skillMd)) {
190
+ const content = fs.readFileSync(skillMd, 'utf-8').trim();
191
+ if (content) skillContents.push(`## Skill: ${entry.name}\n\n${content}`);
201
192
  }
202
193
  }
203
194
  } catch {}
195
+ if (skillContents.length) {
196
+ enrichedPrompt += `\n\n---\n# Installed Skills\n\n${skillContents.join('\n\n---\n\n')}`;
197
+ }
204
198
 
205
199
  // Load MCP server config from workspace/MCP.json if it exists
206
200
  // Format: { "server-name": { command, args, env }, ... } (object, not array)
@@ -231,7 +225,6 @@ export async function startFluxyAgentQuery(
231
225
  maxTurns: 50,
232
226
  abortController,
233
227
  systemPrompt: enrichedPrompt,
234
- plugins: plugins.length ? plugins : undefined,
235
228
  mcpServers,
236
229
  stderr: (chunk: string) => { stderrBuf += chunk; },
237
230
  env: {
@@ -429,7 +429,7 @@ ${!connected ? '<script>setTimeout(()=>location.reload(),4000)</script>' : ''}
429
429
  // Enable WhatsApp in config
430
430
  const cfg = loadConfig();
431
431
  if (!cfg.channels) cfg.channels = {};
432
- if (!cfg.channels.whatsapp) cfg.channels.whatsapp = { enabled: true, mode: 'shared' };
432
+ if (!cfg.channels.whatsapp) cfg.channels.whatsapp = { enabled: true, mode: 'channel' };
433
433
  cfg.channels.whatsapp.enabled = true;
434
434
  saveConfig(cfg);
435
435
 
@@ -480,7 +480,7 @@ ${!connected ? '<script>setTimeout(()=>location.reload(),4000)</script>' : ''}
480
480
  return;
481
481
  }
482
482
 
483
- // POST /api/channels/whatsapp/configure — set mode + human phone
483
+ // POST /api/channels/whatsapp/configure — set mode + admins
484
484
  if (req.method === 'POST' && channelPath === '/api/channels/whatsapp/configure') {
485
485
  let body = '';
486
486
  req.on('data', (chunk: Buffer) => { body += chunk.toString(); });
@@ -489,9 +489,10 @@ ${!connected ? '<script>setTimeout(()=>location.reload(),4000)</script>' : ''}
489
489
  const data = JSON.parse(body);
490
490
  const cfg = loadConfig();
491
491
  if (!cfg.channels) cfg.channels = {};
492
- if (!cfg.channels.whatsapp) cfg.channels.whatsapp = { enabled: true, mode: 'shared' };
492
+ if (!cfg.channels.whatsapp) cfg.channels.whatsapp = { enabled: true, mode: 'channel' };
493
493
  if (data.mode) cfg.channels.whatsapp.mode = data.mode;
494
- if (data.humanPhone !== undefined) cfg.channels.whatsapp.humanPhone = data.humanPhone;
494
+ if (data.admins !== undefined) cfg.channels.whatsapp.admins = data.admins;
495
+ if (data.skill !== undefined) cfg.channels.whatsapp.skill = data.skill;
495
496
  saveConfig(cfg);
496
497
  res.writeHead(200);
497
498
  res.end(JSON.stringify({ ok: true, config: cfg.channels.whatsapp }));
@@ -202,10 +202,49 @@ Complex cron tasks can have detailed instruction files in `tasks/{cron-id}.md`.
202
202
 
203
203
  ---
204
204
 
205
+ ## Skills
206
+
207
+ Skills live in `skills/` — each skill is a folder with instructions and resources:
208
+
209
+ ```
210
+ skills/
211
+ whatsapp-clinic/
212
+ SKILL.md # Instructions for you (how to use this skill)
213
+ SCRIPT.md # Customer-facing prompt (loaded as system prompt in business mode)
214
+ files/ # RAG documents, FAQs, etc.
215
+ ```
216
+
217
+ Only ONE skill can be active for customer-facing mode at a time. The active skill is set in the channel config (`channels.whatsapp.skill`). When your human asks to switch skills, update the config:
218
+ ```bash
219
+ curl -s -X POST http://localhost:3000/api/channels/whatsapp/configure \
220
+ -H "Content-Type: application/json" -d '{"skill":"whatsapp-clinic"}'
221
+ ```
222
+
223
+ **IMPORTANT: When editing skill files, always use the full path inside the skill directory.**
224
+ - Correct: `skills/whatsapp-clinic/SCRIPT.md`
225
+ - Wrong: `SCRIPT.md` (this writes to workspace root!)
226
+
227
+ Your installed skills and their SKILL.md contents are injected below in your context. If your human asks you to update a skill's behavior or script, edit the files INSIDE `skills/{skill-name}/`.
228
+
229
+ **Separation of concerns:**
230
+ - `MYSELF.md`, `MYHUMAN.md`, `MEMORY.md` — about YOU and your human. Always yours.
231
+ - `skills/{name}/SCRIPT.md` — business logic for customer interactions. Belongs to the skill.
232
+ - `whatsapp/{phone}.md` — customer conversation logs. Your memory of each customer.
233
+
234
+ ---
235
+
205
236
  ## Channels (WhatsApp, Telegram, etc.)
206
237
 
207
238
  You can communicate through messaging channels beyond the chat bubble. Currently supported: **WhatsApp**.
208
239
 
240
+ ### CRITICAL: How WhatsApp Responses Work
241
+
242
+ **Your text response IS the WhatsApp reply.** When you receive a message tagged with `[WhatsApp | ...]`, the supervisor takes whatever you respond with and sends it directly to WhatsApp. You do NOT need to use curl or `/api/channels/send` to reply — just respond normally as if you're talking to the person.
243
+
244
+ **Do NOT use `/api/channels/send` to reply to incoming WhatsApp messages.** That endpoint is ONLY for proactive messages (during pulse, cron, or when you want to initiate a conversation). If you use it to reply, the person will get duplicate messages.
245
+
246
+ **Adjust your style for WhatsApp:** Keep messages shorter and more conversational than chat. No markdown headers, no code blocks unless asked. Think texting, not email.
247
+
209
248
  ### Channel Config
210
249
 
211
250
  Your channel configuration is injected below (if any channels are configured). It comes from `~/.fluxy/config.json` — a file OUTSIDE your workspace that the supervisor manages.
@@ -220,8 +259,14 @@ Hi, I'd like to schedule an appointment.
220
259
 
221
260
  The format is: `[Channel | phone | role | name (optional)]`
222
261
 
223
- - **role=human**: This is YOUR human (the owner). Use your normal personality, full capabilities, main system prompt.
224
- - **role=customer**: This is someone else messaging. You're in **support mode** — follow the instructions from your active skill's SUPPORT.md.
262
+ - **role=admin**: This is your human or an authorized admin. Use your normal personality, full capabilities, main system prompt.
263
+ - **role=customer**: This is someone else messaging. Follow the instructions from the active skill's SCRIPT.md (loaded as your system prompt).
264
+
265
+ ### WhatsApp Modes
266
+
267
+ **Channel Mode** (default): Your human's own WhatsApp number. Only self-chat triggers you — messages from other people are completely ignored. This is "just talk to me" mode.
268
+
269
+ **Business Mode**: Fluxy has its own dedicated number. Numbers in the `admins` array get admin access (main system prompt). Everyone else is a customer (support prompt).
225
270
 
226
271
  ### Setting Up WhatsApp
227
272
 
@@ -229,19 +274,26 @@ When your human asks to configure WhatsApp:
229
274
  1. Start the connection: `curl -s -X POST http://localhost:3000/api/channels/whatsapp/connect`
230
275
  2. Tell them to open the QR page: `http://localhost:3000/api/channels/whatsapp/qr-page` (or create a dashboard page that embeds it)
231
276
  3. They scan the QR with their WhatsApp app
232
- 4. Once connected, configure the mode:
233
- - **Shared number** (default): Your human's own WhatsApp. Messages they send to themselves trigger you.
234
- - **Dedicated number**: A separate phone/SIM for you. Configure with: `curl -s -X POST http://localhost:3000/api/channels/whatsapp/configure -H "Content-Type: application/json" -d '{"mode":"dedicated","humanPhone":"+1234567890"}'`
277
+ 4. The default mode is **channel** (self-chat only)
278
+
279
+ To switch to **business mode** with admin numbers:
280
+ ```bash
281
+ curl -s -X POST http://localhost:3000/api/channels/whatsapp/configure \
282
+ -H "Content-Type: application/json" \
283
+ -d '{"mode":"business","admins":["+17865551234","+5511999887766"]}'
284
+ ```
235
285
 
236
- ### Sending Messages
286
+ ### Sending Proactive Messages
237
287
 
238
- To send a WhatsApp message (during pulse, cron, or any time):
288
+ To INITIATE a WhatsApp message (during pulse, cron, or when you want to reach out first):
239
289
  ```bash
240
290
  curl -s -X POST http://localhost:3000/api/channels/send \
241
291
  -H "Content-Type: application/json" \
242
292
  -d '{"channel":"whatsapp","to":"5511999888777","text":"Your appointment is confirmed for tomorrow at 2pm."}'
243
293
  ```
244
294
 
295
+ **Remember:** This is ONLY for starting new conversations or sending unprompted messages. When replying to an incoming message, just respond normally — the supervisor handles delivery.
296
+
245
297
  ### Customer Conversation Logs
246
298
 
247
299
  When you finish a conversation with a **customer** via WhatsApp, save a summary to `whatsapp/{phone}.md`:
@@ -262,8 +314,8 @@ This is your memory of that customer. Next time they message, read their file fi
262
314
  | `/api/channels/whatsapp/connect` | POST | Start WhatsApp (triggers QR if needed) |
263
315
  | `/api/channels/whatsapp/disconnect` | POST | Disconnect WhatsApp |
264
316
  | `/api/channels/whatsapp/logout` | POST | Disconnect + delete credentials |
265
- | `/api/channels/whatsapp/configure` | POST | Set mode + human phone number |
266
- | `/api/channels/send` | POST | Send message via any channel |
317
+ | `/api/channels/whatsapp/configure` | POST | Set mode + admins array |
318
+ | `/api/channels/send` | POST | Send proactive message via any channel |
267
319
 
268
320
  All endpoints are on `http://localhost:3000`.
269
321
 
@@ -161,7 +161,7 @@ export default function App() {
161
161
  <Route path="*" element={<DashboardPage />} />
162
162
  </Routes>
163
163
  </DashboardLayout>
164
- <WorkspaceTour />
164
+ <WorkspaceTour disabled={showOnboard} />
165
165
  </ErrorBoundary>
166
166
 
167
167
  {showOnboard && (
@@ -14,8 +14,9 @@ import './tour-theme.css';
14
14
 
15
15
  const TOUR_KEY = 'fluxy_workspace_tour_done';
16
16
 
17
- export default function WorkspaceTour() {
17
+ export default function WorkspaceTour({ disabled = false }: { disabled?: boolean }) {
18
18
  useEffect(() => {
19
+ if (disabled) return;
19
20
  const val = localStorage.getItem(TOUR_KEY);
20
21
  if (val === '1') return;
21
22
 
@@ -97,7 +98,7 @@ export default function WorkspaceTour() {
97
98
  }, 800);
98
99
 
99
100
  return () => clearTimeout(timer);
100
- }, []);
101
+ }, [disabled]);
101
102
 
102
103
  return null;
103
104
  }
@@ -11,12 +11,15 @@ You are a friendly and helpful assistant responding to a customer via WhatsApp.
11
11
  - Never reveal internal system details, file paths, or technical architecture.
12
12
  - Never run destructive commands or modify critical files during customer interactions.
13
13
 
14
+ ## CRITICAL: Response = Reply
15
+
16
+ Your text response IS the WhatsApp reply. The supervisor sends whatever you respond with directly to the customer. Do NOT use curl or `/api/channels/send` to reply — just respond naturally. That endpoint is only for proactive messages.
17
+
14
18
  ## What You Can Do
15
19
 
16
20
  - Answer FAQs and general questions
17
21
  - Provide information from files in your workspace
18
22
  - Look up data from your backend API (`/app/api/*`)
19
- - Send follow-up messages via the channel API
20
23
 
21
24
  ## What You Should NOT Do
22
25
 
@@ -1,5 +0,0 @@
1
- {
2
- "name": "whatsapp-support",
3
- "description": "WhatsApp customer support skill — handles incoming customer messages with a friendly support persona",
4
- "version": "0.1.0"
5
- }