fluxy-bot 0.15.2 → 0.15.6
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 +1 -1
- package/shared/config.ts +3 -1
- package/supervisor/channels/manager.ts +97 -34
- package/supervisor/channels/types.ts +3 -1
- package/supervisor/channels/whatsapp.ts +28 -17
- package/supervisor/fluxy-agent.ts +12 -19
- package/supervisor/index.ts +46 -8
- package/worker/prompts/fluxy-system-prompt.txt +32 -1
- package/workspace/skills/whatsapp-support/.claude-plugin/plugin.json +0 -5
- /package/workspace/skills/whatsapp-support/{SUPPORT.md → SCRIPT.md} +0 -0
package/package.json
CHANGED
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
|
|
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:
|
|
183
|
+
// ── Channel mode: ONLY respond to self-chat ──
|
|
175
184
|
if (mode === 'channel') {
|
|
176
|
-
if (!fromMe) {
|
|
177
|
-
// Ignore
|
|
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:
|
|
196
|
-
|
|
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
|
|
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
|
|
347
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
383
|
-
|
|
438
|
+
recentMessages,
|
|
439
|
+
enrichedScript,
|
|
384
440
|
);
|
|
385
441
|
}
|
|
386
442
|
|
|
387
|
-
/** Load
|
|
388
|
-
private
|
|
389
|
-
const
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
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
|
|
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';
|
|
@@ -120,18 +120,11 @@ export class WhatsAppChannel implements ChannelProvider {
|
|
|
120
120
|
// If it's already a phone JID, return as-is
|
|
121
121
|
if (jid.endsWith('@s.whatsapp.net')) return jid;
|
|
122
122
|
|
|
123
|
-
// Check LID map
|
|
123
|
+
// Check LID map (only contains our own LID, mapped during connection)
|
|
124
124
|
const mapped = this.lidToPhoneMap.get(jid);
|
|
125
125
|
if (mapped) return mapped;
|
|
126
126
|
|
|
127
|
-
//
|
|
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
|
-
|
|
127
|
+
// Unknown LID — don't guess. Return as-is so isSelfChat stays false.
|
|
135
128
|
return jid;
|
|
136
129
|
}
|
|
137
130
|
|
|
@@ -144,11 +137,18 @@ export class WhatsAppChannel implements ChannelProvider {
|
|
|
144
137
|
const phone = user.id.split(':')[0];
|
|
145
138
|
this.ownPhoneJid = `${phone}@s.whatsapp.net`;
|
|
146
139
|
|
|
147
|
-
// user.lid (if available) is the LID JID
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
140
|
+
// user.lid (if available) is the LID JID — map it and variations
|
|
141
|
+
const lid = (user as any).lid;
|
|
142
|
+
if (lid) {
|
|
143
|
+
// Map the exact LID
|
|
144
|
+
this.lidToPhoneMap.set(lid, this.ownPhoneJid);
|
|
145
|
+
// Also map the numeric part without @lid suffix (remoteJid may use @lid format)
|
|
146
|
+
const lidNum = lid.split(':')[0].split('@')[0];
|
|
147
|
+
this.lidToPhoneMap.set(`${lidNum}@lid`, this.ownPhoneJid);
|
|
148
|
+
log.info(`[whatsapp] LID map: ${lid} (and ${lidNum}@lid) → ${this.ownPhoneJid}`);
|
|
151
149
|
}
|
|
150
|
+
|
|
151
|
+
log.info(`[whatsapp] Own phone JID: ${this.ownPhoneJid}`);
|
|
152
152
|
}
|
|
153
153
|
|
|
154
154
|
private async connectInternal(): Promise<void> {
|
|
@@ -250,14 +250,25 @@ export class WhatsAppChannel implements ChannelProvider {
|
|
|
250
250
|
|
|
251
251
|
const fromMe = msg.key.fromMe || false;
|
|
252
252
|
const rawSender = msg.key.remoteJid || '';
|
|
253
|
+
const participant = msg.key.participant || '';
|
|
254
|
+
|
|
255
|
+
// Debug: log all available sender fields to find the actual sender
|
|
256
|
+
log.info(`[whatsapp] DEBUG msg.key: remoteJid=${rawSender} fromMe=${fromMe} participant=${participant}`);
|
|
257
|
+
log.info(`[whatsapp] DEBUG pushName=${msg.pushName || 'none'} verifiedBizName=${(msg as any).verifiedBizName || 'none'}`);
|
|
258
|
+
|
|
259
|
+
// The actual sender JID — use participant if available (newer protocol), fallback to remoteJid
|
|
260
|
+
const actualSender = participant || rawSender;
|
|
253
261
|
|
|
254
262
|
// Translate LID JIDs to phone JIDs
|
|
255
|
-
const sender = this.translateJid(
|
|
263
|
+
const sender = this.translateJid(actualSender);
|
|
256
264
|
const pushName = msg.pushName || undefined;
|
|
257
265
|
|
|
258
|
-
|
|
266
|
+
// Detect self-chat: remoteJid matches our own phone number AND no participant field
|
|
267
|
+
const isSelfChat = !participant && this.ownPhoneJid !== null && this.translateJid(rawSender) === this.ownPhoneJid;
|
|
268
|
+
|
|
269
|
+
log.info(`[whatsapp] Message from ${sender} (raw=${rawSender}, participant=${participant}, fromMe=${fromMe}, selfChat=${isSelfChat}): ${text.slice(0, 80)}`);
|
|
259
270
|
|
|
260
|
-
this.onMessage(sender, pushName, text, fromMe);
|
|
271
|
+
this.onMessage(sender, pushName, text, fromMe, isSelfChat);
|
|
261
272
|
}
|
|
262
273
|
});
|
|
263
274
|
}
|
|
@@ -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 {
|
|
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
|
-
|
|
183
|
-
|
|
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()
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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: {
|
package/supervisor/index.ts
CHANGED
|
@@ -403,20 +403,57 @@ export async function startSupervisor() {
|
|
|
403
403
|
const status = channelManager.getStatus('whatsapp');
|
|
404
404
|
const connected = status?.connected || false;
|
|
405
405
|
res.writeHead(200);
|
|
406
|
+
const confettiHTML = Array.from({ length: 30 }, (_, i) => {
|
|
407
|
+
const colors = ['#04D1FE', '#AF27E3', '#FB4072', '#4ade80', '#facc15', '#818cf8'];
|
|
408
|
+
const color = colors[Math.floor(Math.random() * colors.length)];
|
|
409
|
+
const left = Math.random() * 100;
|
|
410
|
+
const delay = i * 0.04;
|
|
411
|
+
const drift = (Math.random() - 0.5) * 120;
|
|
412
|
+
const duration = 1.8 + Math.random() * 0.8;
|
|
413
|
+
return `<div class="confetti-dot" style="left:${left}%;background:${color};animation-delay:${delay}s;animation-duration:${duration}s;--drift:${drift}px"></div>`;
|
|
414
|
+
}).join('');
|
|
415
|
+
|
|
406
416
|
res.end(`<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
407
417
|
<title>WhatsApp QR</title>
|
|
418
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
419
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
420
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Space+Grotesk:wght@600;700&display=swap" rel="stylesheet">
|
|
408
421
|
<style>
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
.
|
|
412
|
-
|
|
413
|
-
.
|
|
422
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
423
|
+
body{background:#212121;color:#f5f5f5;font-family:'Inter',system-ui,-apple-system,sans-serif;display:flex;flex-direction:column;align-items:center;justify-content:center;height:100dvh;margin:0;overflow:hidden}
|
|
424
|
+
.container{display:flex;flex-direction:column;align-items:center;max-width:360px;width:100%;padding:0 20px}
|
|
425
|
+
|
|
426
|
+
.qr-card{background:#2a2a2a;border:1px solid rgba(255,255,255,0.08);border-radius:20px;padding:28px;width:100%;box-shadow:0 0 0 1px rgba(175,39,227,0.1),0 0 20px -5px rgba(175,39,227,0.15);animation:fade-up .5s ease-out both}
|
|
427
|
+
.qr-inner{background:#fff;border-radius:12px;padding:16px}
|
|
428
|
+
.qr-inner svg{width:100%;height:auto;display:block}
|
|
429
|
+
|
|
430
|
+
.scan-hint{margin-top:20px;font-size:13px;color:#666;text-align:center;animation:fade-up .5s ease-out .2s both}
|
|
431
|
+
|
|
432
|
+
.confetti-wrap{position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;overflow:hidden}
|
|
433
|
+
.confetti-dot{position:absolute;width:8px;height:8px;border-radius:50%;top:-10px;animation:confetti-fall 2s ease-out forwards}
|
|
434
|
+
@keyframes confetti-fall{0%{opacity:1;transform:translateY(0) translateX(0) rotate(0) scale(1)}100%{opacity:0;transform:translateY(100vh) translateX(var(--drift)) rotate(360deg) scale(.5)}}
|
|
435
|
+
|
|
436
|
+
.video-wrap{margin-bottom:8px;animation:pop-in .5s cubic-bezier(.34,1.56,.64,1) forwards}
|
|
437
|
+
.video-wrap video{width:200px;object-fit:contain}
|
|
438
|
+
@keyframes pop-in{0%{transform:scale(0);opacity:0}100%{transform:scale(1);opacity:1}}
|
|
439
|
+
|
|
440
|
+
.text-wrap{text-align:center;animation:fade-up .5s ease-out .3s both}
|
|
441
|
+
.title{font-family:'Space Grotesk',sans-serif;font-size:22px;font-weight:700;background:linear-gradient(135deg,#04D1FE,#AF27E3,#FB4072);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;margin-bottom:8px}
|
|
442
|
+
.subtitle{font-size:14px;color:#999;line-height:1.5}
|
|
443
|
+
|
|
444
|
+
.loading{font-size:14px;color:#999;animation:pulse 2s ease-in-out infinite}
|
|
445
|
+
@keyframes pulse{0%,100%{opacity:.5}50%{opacity:1}}
|
|
446
|
+
@keyframes fade-up{0%{opacity:0;transform:translateY(10px)}100%{opacity:1;transform:translateY(0)}}
|
|
414
447
|
</style></head><body>
|
|
448
|
+
<div class="container">
|
|
415
449
|
${connected
|
|
416
|
-
?
|
|
450
|
+
? `<div class="confetti-wrap">${confettiHTML}</div>
|
|
451
|
+
<div class="video-wrap"><video autoplay muted playsinline><source src="/fluxy_happy_reappearing.mov" type='video/mp4; codecs="hvc1"'><source src="/fluxy_happy_reappearing.webm" type="video/webm"></video></div>
|
|
452
|
+
<div class="text-wrap"><div class="title">Connected!</div><p class="subtitle">WhatsApp is linked. You can close this page.</p></div>`
|
|
417
453
|
: qr
|
|
418
|
-
? `<div class="qr-
|
|
419
|
-
: '<p class="
|
|
454
|
+
? `<div class="qr-card"><div class="qr-inner">${qr}</div></div><p class="scan-hint">Scan with WhatsApp to link</p>`
|
|
455
|
+
: '<p class="loading">Starting WhatsApp... Refresh in a moment.</p>'}
|
|
456
|
+
</div>
|
|
420
457
|
${!connected ? '<script>setTimeout(()=>location.reload(),4000)</script>' : ''}
|
|
421
458
|
</body></html>`);
|
|
422
459
|
return;
|
|
@@ -492,6 +529,7 @@ ${!connected ? '<script>setTimeout(()=>location.reload(),4000)</script>' : ''}
|
|
|
492
529
|
if (!cfg.channels.whatsapp) cfg.channels.whatsapp = { enabled: true, mode: 'channel' };
|
|
493
530
|
if (data.mode) cfg.channels.whatsapp.mode = data.mode;
|
|
494
531
|
if (data.admins !== undefined) cfg.channels.whatsapp.admins = data.admins;
|
|
532
|
+
if (data.skill !== undefined) cfg.channels.whatsapp.skill = data.skill;
|
|
495
533
|
saveConfig(cfg);
|
|
496
534
|
res.writeHead(200);
|
|
497
535
|
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.
|
|
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
|
|
|
File without changes
|