fluxy-bot 0.15.2 → 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.2",
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
- /** 'channel' = just talk to me (self-chat only), 'business' = admin/customer support */
6
+ /** 'channel' = just talk to me (self-chat only), 'business' = admin/customer mode */
7
7
  mode: 'channel' | 'business';
8
8
  /** Phone numbers with admin access (owner, secretary, etc.) — business mode only */
9
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 {
@@ -25,6 +25,7 @@ import { WhatsAppChannel } from './whatsapp.js';
25
25
  import type { ChannelConfig, ChannelProvider, ChannelStatus, ChannelType, InboundMessage, SenderRole } from './types.js';
26
26
 
27
27
  const MAX_CONCURRENT_AGENTS = 5;
28
+ const MAX_BUFFER_MESSAGES = 30;
28
29
 
29
30
  interface ChannelManagerOpts {
30
31
  broadcastFluxy: (type: string, data: any) => void;
@@ -38,12 +39,19 @@ interface ActiveAgentQuery {
38
39
  channel: ChannelType;
39
40
  }
40
41
 
42
+ interface BufferedMessage {
43
+ role: 'user' | 'assistant';
44
+ content: string;
45
+ }
46
+
41
47
  export class ChannelManager {
42
48
  private providers = new Map<ChannelType, ChannelProvider>();
43
49
  private opts: ChannelManagerOpts;
44
50
  private activeAgents = new Map<string, ActiveAgentQuery>();
45
51
  private messageQueue: InboundMessage[] = [];
46
52
  private statusListeners: ((status: ChannelStatus) => void)[] = [];
53
+ /** In-memory conversation history per customer (keyed by "channel:phone") */
54
+ private customerBuffers = new Map<string, BufferedMessage[]>();
47
55
 
48
56
  constructor(opts: ChannelManagerOpts) {
49
57
  this.opts = opts;
@@ -61,7 +69,7 @@ export class ChannelManager {
61
69
 
62
70
  log.info('[channels] Initializing WhatsApp channel...');
63
71
  const whatsapp = new WhatsAppChannel(
64
- (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),
65
73
  (status) => this.handleStatusChange(status),
66
74
  );
67
75
  this.providers.set('whatsapp', whatsapp);
@@ -81,7 +89,7 @@ export class ChannelManager {
81
89
  let provider = this.providers.get('whatsapp');
82
90
  if (!provider) {
83
91
  const whatsapp = new WhatsAppChannel(
84
- (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),
85
93
  (status) => this.handleStatusChange(status),
86
94
  );
87
95
  this.providers.set('whatsapp', whatsapp);
@@ -165,16 +173,17 @@ export class ChannelManager {
165
173
  senderName: string | undefined,
166
174
  text: string,
167
175
  fromMe: boolean,
176
+ isSelfChat: boolean,
168
177
  ) {
169
178
  const channelConfig = this.getChannelConfig(channel);
170
179
  if (!channelConfig) return;
171
180
 
172
181
  const mode = channelConfig.mode || 'channel';
173
182
 
174
- // ── Channel mode: only respond to self-chat (fromMe=true) ──
183
+ // ── Channel mode: ONLY respond to self-chat ──
175
184
  if (mode === 'channel') {
176
- if (!fromMe) {
177
- // Ignore messages from other people — this is the user's personal WhatsApp
185
+ if (!fromMe || !isSelfChat) {
186
+ // Ignore everything except self-chat messages
178
187
  return;
179
188
  }
180
189
 
@@ -192,8 +201,16 @@ export class ChannelManager {
192
201
  return;
193
202
  }
194
203
 
195
- // ── Business mode: resolve role based on admins array ──
196
- const role = this.resolveBusinessRole(channelConfig, sender, fromMe);
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);
197
214
 
198
215
  const message: InboundMessage = {
199
216
  channel,
@@ -209,16 +226,12 @@ export class ChannelManager {
209
226
  if (role === 'admin') {
210
227
  await this.handleAdminMessage(message);
211
228
  } else {
212
- await this.handleCustomerMessage(message);
229
+ await this.handleCustomerMessage(message, channelConfig);
213
230
  }
214
231
  }
215
232
 
216
233
  /** Resolve role in business mode — check admins array */
217
- private resolveBusinessRole(config: ChannelConfig, sender: string, fromMe: boolean): SenderRole {
218
- // fromMe is always admin (the number Fluxy is connected with)
219
- if (fromMe) return 'admin';
220
-
221
- // Check admins array
234
+ private resolveBusinessRole(config: ChannelConfig, sender: string): SenderRole {
222
235
  if (config.admins?.length) {
223
236
  const senderPhone = sender.replace(/@.*/, '').replace(/[^0-9]/g, '');
224
237
  for (const admin of config.admins) {
@@ -329,8 +342,8 @@ export class ChannelManager {
329
342
  );
330
343
  }
331
344
 
332
- /** Handle message from a customer — runs support agent in parallel */
333
- 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) {
334
347
  const agentKey = `${msg.channel}:${msg.sender}`;
335
348
 
336
349
  // Check concurrent limit
@@ -343,8 +356,8 @@ export class ChannelManager {
343
356
  const { workerApi, getModel } = this.opts;
344
357
  const model = getModel();
345
358
 
346
- // Load support system prompt from skill
347
- const supportPrompt = this.loadSupportPrompt();
359
+ // Load the active skill's SCRIPT.md as the customer-facing system prompt
360
+ const scriptPrompt = this.loadActiveScript(channelConfig);
348
361
 
349
362
  // Fetch agent name
350
363
  let botName = 'Fluxy', humanName = 'Human';
@@ -354,17 +367,60 @@ export class ChannelManager {
354
367
  humanName = status.userName || 'Human';
355
368
  } catch {}
356
369
 
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
+
357
399
  const channelContext = `[WhatsApp | ${msg.sender} | customer${msg.senderName ? ` | ${msg.senderName}` : ''}]\n`;
358
- const convId = `channel-${agentKey}-${Date.now()}`;
400
+
401
+ // Stable convId per customer (not per message)
402
+ const convId = `channel-${agentKey}`;
359
403
 
360
404
  this.activeAgents.set(agentKey, { sender: msg.sender, channel: msg.channel });
361
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
+
362
412
  startFluxyAgentQuery(
363
413
  convId,
364
414
  channelContext + msg.text,
365
415
  model,
366
416
  (type, eventData) => {
367
417
  if (type === 'bot:response' && eventData.content) {
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
+
368
424
  this.sendMessage(msg.channel, msg.rawSender, eventData.content).catch((err) => {
369
425
  log.warn(`[channels] Failed to send customer reply: ${err.message}`);
370
426
  });
@@ -379,27 +435,32 @@ export class ChannelManager {
379
435
  undefined,
380
436
  undefined,
381
437
  { botName, humanName },
382
- undefined,
383
- supportPrompt,
438
+ recentMessages,
439
+ enrichedScript,
384
440
  );
385
441
  }
386
442
 
387
- /** Load customer-facing system prompt from skills */
388
- private loadSupportPrompt(): string | undefined {
389
- 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');
390
452
  try {
391
- for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
392
- if (!entry.isDirectory()) continue;
393
- const supportPath = path.join(skillsDir, entry.name, 'SUPPORT.md');
394
- if (fs.existsSync(supportPath)) {
395
- const content = fs.readFileSync(supportPath, 'utf-8').trim();
396
- if (content) {
397
- log.info(`[channels] Loaded support prompt from skill: ${entry.name}`);
398
- return content;
399
- }
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;
400
458
  }
401
459
  }
402
- } 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
+ }
403
464
  return undefined;
404
465
  }
405
466
 
@@ -407,8 +468,10 @@ export class ChannelManager {
407
468
  private processQueue() {
408
469
  while (this.messageQueue.length > 0 && this.activeAgents.size < MAX_CONCURRENT_AGENTS) {
409
470
  const queued = this.messageQueue.shift()!;
471
+ const config = this.getChannelConfig(queued.channel);
472
+ if (!config) continue;
410
473
  log.info(`[channels] Processing queued message from ${queued.sender}`);
411
- this.handleCustomerMessage(queued);
474
+ this.handleCustomerMessage(queued, config);
412
475
  }
413
476
  }
414
477
  }
@@ -7,10 +7,12 @@ export type SenderRole = 'admin' | 'customer';
7
7
 
8
8
  export interface ChannelConfig {
9
9
  enabled: boolean;
10
- /** 'channel' = just talk to me (self-chat only), 'business' = admin/customer support */
10
+ /** 'channel' = just talk to me (self-chat only), 'business' = admin/customer mode */
11
11
  mode: 'channel' | 'business';
12
12
  /** Phone numbers with admin access (owner, secretary, etc.) — business mode only */
13
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 {
@@ -23,7 +23,7 @@ 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';
@@ -255,9 +255,12 @@ export class WhatsAppChannel implements ChannelProvider {
255
255
  const sender = this.translateJid(rawSender);
256
256
  const pushName = msg.pushName || undefined;
257
257
 
258
- log.info(`[whatsapp] Message from ${sender} (raw=${rawSender}, 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;
259
260
 
260
- this.onMessage(sender, pushName, text, fromMe);
261
+ log.info(`[whatsapp] Message from ${sender} (raw=${rawSender}, fromMe=${fromMe}, selfChat=${isSelfChat}): ${text.slice(0, 80)}`);
262
+
263
+ this.onMessage(sender, pushName, text, fromMe, isSelfChat);
261
264
  }
262
265
  });
263
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: {
@@ -492,6 +492,7 @@ ${!connected ? '<script>setTimeout(()=>location.reload(),4000)</script>' : ''}
492
492
  if (!cfg.channels.whatsapp) cfg.channels.whatsapp = { enabled: true, mode: 'channel' };
493
493
  if (data.mode) cfg.channels.whatsapp.mode = data.mode;
494
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,6 +202,37 @@ 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**.
@@ -229,7 +260,7 @@ Hi, I'd like to schedule an appointment.
229
260
  The format is: `[Channel | phone | role | name (optional)]`
230
261
 
231
262
  - **role=admin**: This is your human or an authorized admin. Use your normal personality, full capabilities, main system prompt.
232
- - **role=customer**: This is someone else messaging. You're in **support mode** — follow the instructions from your active skill's SUPPORT.md.
263
+ - **role=customer**: This is someone else messaging. Follow the instructions from the active skill's SCRIPT.md (loaded as your system prompt).
233
264
 
234
265
  ### WhatsApp Modes
235
266
 
@@ -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
- }