@whimmy-ai/whimmy 0.3.0 → 0.4.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.
- package/index.ts +1 -6
- package/package.json +1 -1
- package/src/channel.ts +89 -25
- package/src/sync.ts +86 -0
- package/src/utils.ts +44 -0
package/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ const plugin = {
|
|
|
17
17
|
// Register a gateway method that captures the ExecApprovalManager reference.
|
|
18
18
|
// The manager is only accessible via GatewayRequestHandlerOptions.context,
|
|
19
19
|
// so we use this method as the capture point.
|
|
20
|
+
// The backend should call this method once after connecting to the gateway.
|
|
20
21
|
api.registerGatewayMethod('whimmy.approval.init', (opts) => {
|
|
21
22
|
const manager = opts.context.execApprovalManager;
|
|
22
23
|
if (manager) {
|
|
@@ -24,12 +25,6 @@ const plugin = {
|
|
|
24
25
|
}
|
|
25
26
|
opts.respond(true, { ok: true });
|
|
26
27
|
});
|
|
27
|
-
|
|
28
|
-
// Once the gateway starts, the approval manager will be available
|
|
29
|
-
// after the first gateway method invocation.
|
|
30
|
-
api.on('gateway_start', () => {
|
|
31
|
-
// ExecApprovalManager is captured on the first whimmy.approval.init call.
|
|
32
|
-
});
|
|
33
28
|
},
|
|
34
29
|
};
|
|
35
30
|
|
package/package.json
CHANGED
package/src/channel.ts
CHANGED
|
@@ -2,7 +2,8 @@ import WebSocket from 'ws';
|
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import type { OpenClawConfig, OpenClawPluginApi } from 'openclaw/plugin-sdk';
|
|
4
4
|
import { getWhimmyRuntime } from './runtime';
|
|
5
|
-
import { resolveConnection, resolveConnectionAsync, buildWsUrl, isConfigured as isConfiguredUtil } from './utils';
|
|
5
|
+
import { resolveConnection, resolveConnectionAsync, buildWsUrl, isConfigured as isConfiguredUtil, uploadFile } from './utils';
|
|
6
|
+
import { ensureWhimmyAgent } from './sync';
|
|
6
7
|
import type {
|
|
7
8
|
WhimmyConfig,
|
|
8
9
|
WhimmyChannelPlugin,
|
|
@@ -126,15 +127,14 @@ async function handleHookAgent(
|
|
|
126
127
|
|
|
127
128
|
log?.info?.(`[Whimmy] Inbound: agent=${request.agentId} session=${request.sessionKey} text="${request.message.slice(0, 80)}..."`);
|
|
128
129
|
|
|
129
|
-
//
|
|
130
|
-
const
|
|
131
|
-
cfg,
|
|
132
|
-
channel: 'whimmy',
|
|
133
|
-
accountId,
|
|
134
|
-
peer: { kind: 'direct', id: request.sessionKey },
|
|
135
|
-
});
|
|
130
|
+
// Sync Whimmy agent config into OpenClaw (model + system prompt).
|
|
131
|
+
const syncedCfg = await ensureWhimmyAgent(request.agentId, request.agentConfig, log);
|
|
136
132
|
|
|
137
|
-
|
|
133
|
+
// Construct session key directly — no need for resolveAgentRoute.
|
|
134
|
+
const agentId = request.agentId;
|
|
135
|
+
const sessionKey = `agent:${agentId}:direct:${request.sessionKey}`.toLowerCase();
|
|
136
|
+
const mainSessionKey = `agent:${agentId}:main`.toLowerCase();
|
|
137
|
+
const storePath = rt.channel.session.resolveStorePath(syncedCfg.session?.store, { agentId });
|
|
138
138
|
|
|
139
139
|
// Build media fields from attachments (following MS Teams / BlueBubbles pattern).
|
|
140
140
|
const attachments = request.attachments ?? [];
|
|
@@ -159,7 +159,7 @@ async function handleHookAgent(
|
|
|
159
159
|
CommandBody: request.message,
|
|
160
160
|
From: request.sessionKey,
|
|
161
161
|
To: request.sessionKey,
|
|
162
|
-
SessionKey:
|
|
162
|
+
SessionKey: sessionKey,
|
|
163
163
|
AccountId: accountId,
|
|
164
164
|
ChatType: 'direct',
|
|
165
165
|
ConversationLabel: `Whimmy ${request.agentId}`,
|
|
@@ -185,10 +185,10 @@ async function handleHookAgent(
|
|
|
185
185
|
// Record session.
|
|
186
186
|
await rt.channel.session.recordInboundSession({
|
|
187
187
|
storePath,
|
|
188
|
-
sessionKey: ctx.SessionKey ||
|
|
188
|
+
sessionKey: ctx.SessionKey || sessionKey,
|
|
189
189
|
ctx,
|
|
190
190
|
updateLastRoute: {
|
|
191
|
-
sessionKey:
|
|
191
|
+
sessionKey: mainSessionKey,
|
|
192
192
|
channel: 'whimmy',
|
|
193
193
|
to: request.sessionKey,
|
|
194
194
|
accountId,
|
|
@@ -216,13 +216,27 @@ async function handleHookAgent(
|
|
|
216
216
|
deliver: async (payload: any) => {
|
|
217
217
|
// Handle media attachments.
|
|
218
218
|
const urls: string[] = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
|
219
|
-
|
|
220
|
-
|
|
219
|
+
const connInfo = activeConnections.get(accountId)?.conn;
|
|
220
|
+
for (let url of urls) {
|
|
221
|
+
let fileName = url.split('/').pop()?.split('?')[0] || 'file';
|
|
222
|
+
let mimeType = payload.mimeType || 'application/octet-stream';
|
|
223
|
+
// Upload local files to the backend first.
|
|
224
|
+
if (connInfo && url.startsWith('/')) {
|
|
225
|
+
try {
|
|
226
|
+
const uploaded = await uploadFile(url, connInfo);
|
|
227
|
+
url = uploaded.url;
|
|
228
|
+
fileName = uploaded.fileName;
|
|
229
|
+
mimeType = uploaded.mimeType;
|
|
230
|
+
} catch (err: any) {
|
|
231
|
+
log?.error?.(`[Whimmy] Failed to upload file ${url}: ${err.message}`);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
221
235
|
const media: ChatMediaPayload = {
|
|
222
236
|
sessionKey: request.sessionKey,
|
|
223
237
|
agentId: request.agentId,
|
|
224
238
|
mediaUrl: url,
|
|
225
|
-
mimeType
|
|
239
|
+
mimeType,
|
|
226
240
|
fileName,
|
|
227
241
|
audioAsVoice: payload.audioAsVoice ?? false,
|
|
228
242
|
};
|
|
@@ -236,7 +250,7 @@ async function handleHookAgent(
|
|
|
236
250
|
|
|
237
251
|
await rt.channel.reply.dispatchReplyFromConfig({
|
|
238
252
|
ctx,
|
|
239
|
-
cfg,
|
|
253
|
+
cfg: syncedCfg,
|
|
240
254
|
dispatcher,
|
|
241
255
|
replyOptions: {
|
|
242
256
|
...replyOptions,
|
|
@@ -357,7 +371,7 @@ function createWhimmyActions(ws: WebSocket, sessionKey: string, agentId: string)
|
|
|
357
371
|
|
|
358
372
|
// Track active connections per account to prevent duplicate connections
|
|
359
373
|
// when the framework retries startAccount.
|
|
360
|
-
const activeConnections = new Map<string, { ws: WebSocket; stopFn: () => void }>();
|
|
374
|
+
const activeConnections = new Map<string, { ws: WebSocket; conn: ConnectionInfo; stopFn: () => void }>();
|
|
361
375
|
|
|
362
376
|
async function connectWebSocket(
|
|
363
377
|
conn: ConnectionInfo,
|
|
@@ -504,7 +518,7 @@ async function connectWebSocket(
|
|
|
504
518
|
log?.info?.(`[Whimmy][${accountId}] Stopped`);
|
|
505
519
|
};
|
|
506
520
|
|
|
507
|
-
const result = { ws, stopFn };
|
|
521
|
+
const result = { ws, conn, stopFn };
|
|
508
522
|
activeConnections.set(accountId, result);
|
|
509
523
|
return result;
|
|
510
524
|
}
|
|
@@ -599,6 +613,18 @@ export const whimmyPlugin: WhimmyChannelPlugin = {
|
|
|
599
613
|
const actions = createWhimmyActions(conn.ws, sessionKey, agentId);
|
|
600
614
|
|
|
601
615
|
switch (action) {
|
|
616
|
+
case 'send': {
|
|
617
|
+
const text = typeof params.text === 'string' ? params.text : '';
|
|
618
|
+
if (!text) return { ok: false, error: 'Missing text' };
|
|
619
|
+
const chunk: ChatChunkPayload = {
|
|
620
|
+
sessionKey,
|
|
621
|
+
agentId,
|
|
622
|
+
content: text,
|
|
623
|
+
done: true,
|
|
624
|
+
};
|
|
625
|
+
sendEvent(conn.ws, 'chat.done', chunk);
|
|
626
|
+
return { ok: true };
|
|
627
|
+
}
|
|
602
628
|
case 'react': {
|
|
603
629
|
const messageId = typeof params.messageId === 'string' ? params.messageId : '';
|
|
604
630
|
const emoji = typeof params.emoji === 'string' ? params.emoji : '';
|
|
@@ -620,18 +646,46 @@ export const whimmyPlugin: WhimmyChannelPlugin = {
|
|
|
620
646
|
return { ok: true };
|
|
621
647
|
}
|
|
622
648
|
case 'sendAttachment': {
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
649
|
+
let mediaUrl = typeof params.mediaUrl === 'string' ? params.mediaUrl : '';
|
|
650
|
+
let fileName = typeof params.fileName === 'string' ? params.fileName : '';
|
|
651
|
+
let mimeType = typeof params.mimeType === 'string' ? params.mimeType : '';
|
|
652
|
+
const filePath = typeof params.filePath === 'string' ? params.filePath : '';
|
|
653
|
+
|
|
654
|
+
// If a local file path is provided, upload it to the backend first.
|
|
655
|
+
if (filePath && !mediaUrl) {
|
|
656
|
+
try {
|
|
657
|
+
const uploaded = await uploadFile(filePath, conn.conn);
|
|
658
|
+
mediaUrl = uploaded.url;
|
|
659
|
+
if (!fileName) fileName = uploaded.fileName;
|
|
660
|
+
if (!mimeType) mimeType = uploaded.mimeType;
|
|
661
|
+
} catch (uploadErr: any) {
|
|
662
|
+
return { ok: false, error: `File upload failed: ${uploadErr.message}` };
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
if (!mediaUrl) return { ok: false, error: 'Missing mediaUrl or filePath' };
|
|
667
|
+
if (!fileName) fileName = mediaUrl.split('/').pop()?.split('?')[0] || 'file';
|
|
668
|
+
if (!mimeType) mimeType = 'application/octet-stream';
|
|
669
|
+
|
|
626
670
|
const media: ChatMediaPayload = {
|
|
627
671
|
sessionKey,
|
|
628
672
|
agentId,
|
|
629
673
|
mediaUrl,
|
|
630
|
-
mimeType
|
|
674
|
+
mimeType,
|
|
631
675
|
fileName,
|
|
632
676
|
audioAsVoice: params.audioAsVoice === true,
|
|
633
677
|
};
|
|
634
678
|
sendEvent(conn.ws, 'chat.media', media);
|
|
679
|
+
// Send caption as a follow-up text message if provided.
|
|
680
|
+
if (typeof params.caption === 'string' && params.caption) {
|
|
681
|
+
const caption: ChatChunkPayload = {
|
|
682
|
+
sessionKey,
|
|
683
|
+
agentId,
|
|
684
|
+
content: params.caption,
|
|
685
|
+
done: true,
|
|
686
|
+
};
|
|
687
|
+
sendEvent(conn.ws, 'chat.done', caption);
|
|
688
|
+
}
|
|
635
689
|
return { ok: true };
|
|
636
690
|
}
|
|
637
691
|
default:
|
|
@@ -648,9 +702,19 @@ export const whimmyPlugin: WhimmyChannelPlugin = {
|
|
|
648
702
|
return { ok: true as const, to: to.trim() };
|
|
649
703
|
},
|
|
650
704
|
sendText: async ({ cfg, to, text, accountId, log }: any) => {
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
705
|
+
const id = accountId ?? 'default';
|
|
706
|
+
const conn = activeConnections.get(id);
|
|
707
|
+
if (!conn || conn.ws.readyState !== WebSocket.OPEN) {
|
|
708
|
+
log?.warn?.(`[Whimmy] sendText: not connected (account=${id})`);
|
|
709
|
+
return { channel: 'whimmy', messageId: randomUUID() };
|
|
710
|
+
}
|
|
711
|
+
const chunk: ChatChunkPayload = {
|
|
712
|
+
sessionKey: to,
|
|
713
|
+
agentId: 'default',
|
|
714
|
+
content: text,
|
|
715
|
+
done: true,
|
|
716
|
+
};
|
|
717
|
+
sendEvent(conn.ws, 'chat.done', chunk);
|
|
654
718
|
log?.debug?.(`[Whimmy] sendText: to=${to} text=${text.slice(0, 80)}`);
|
|
655
719
|
return {
|
|
656
720
|
channel: 'whimmy',
|
package/src/sync.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import type { OpenClawConfig } from 'openclaw/plugin-sdk';
|
|
6
|
+
import { getWhimmyRuntime } from './runtime';
|
|
7
|
+
import type { AgentConfig, Logger } from './types';
|
|
8
|
+
|
|
9
|
+
/** In-memory cache: agentId → hash of { model, systemPrompt }. */
|
|
10
|
+
const hashCache = new Map<string, string>();
|
|
11
|
+
|
|
12
|
+
function computeHash(agentConfig: AgentConfig): string {
|
|
13
|
+
const data = JSON.stringify({
|
|
14
|
+
model: agentConfig.model,
|
|
15
|
+
systemPrompt: agentConfig.systemPrompt ?? '',
|
|
16
|
+
});
|
|
17
|
+
return createHash('sha256').update(data).digest('hex');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Ensure the Whimmy agent exists in OpenClaw's config with the correct
|
|
22
|
+
* model and system prompt. Uses an in-memory hash cache to skip redundant
|
|
23
|
+
* disk writes when nothing changed.
|
|
24
|
+
*/
|
|
25
|
+
export async function ensureWhimmyAgent(
|
|
26
|
+
agentId: string,
|
|
27
|
+
agentConfig: AgentConfig,
|
|
28
|
+
log?: Logger,
|
|
29
|
+
): Promise<OpenClawConfig> {
|
|
30
|
+
const rt = getWhimmyRuntime();
|
|
31
|
+
const hash = computeHash(agentConfig);
|
|
32
|
+
|
|
33
|
+
// Fast path: nothing changed since last sync.
|
|
34
|
+
if (hashCache.get(agentId) === hash) {
|
|
35
|
+
log?.debug?.(`[Whimmy] Agent config unchanged for ${agentId}, skipping sync`);
|
|
36
|
+
return rt.config.loadConfig();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
log?.info?.(`[Whimmy] Syncing agent config for ${agentId}`);
|
|
40
|
+
|
|
41
|
+
const cfg = rt.config.loadConfig();
|
|
42
|
+
|
|
43
|
+
// Ensure agents.list exists.
|
|
44
|
+
if (!cfg.agents) {
|
|
45
|
+
(cfg as any).agents = {};
|
|
46
|
+
}
|
|
47
|
+
if (!cfg.agents!.list) {
|
|
48
|
+
cfg.agents!.list = [];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Find or create agent entry.
|
|
52
|
+
let entry = cfg.agents!.list!.find((a) => a.id === agentId);
|
|
53
|
+
if (!entry) {
|
|
54
|
+
cfg.agents!.list!.push({ id: agentId });
|
|
55
|
+
entry = cfg.agents!.list![cfg.agents!.list!.length - 1];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Set model.
|
|
59
|
+
entry!.model = agentConfig.model;
|
|
60
|
+
|
|
61
|
+
// Resolve workspace dir and write SOUL.md.
|
|
62
|
+
const workspace = cfg.agents?.defaults?.workspace
|
|
63
|
+
?? join(homedir(), '.openclaw', 'workspace');
|
|
64
|
+
const agentDir = join(workspace, 'agents', agentId);
|
|
65
|
+
|
|
66
|
+
mkdirSync(agentDir, { recursive: true });
|
|
67
|
+
|
|
68
|
+
// Write SOUL.md with the system prompt (or empty to clear a previous one).
|
|
69
|
+
writeFileSync(join(agentDir, 'SOUL.md'), agentConfig.systemPrompt ?? '', 'utf-8');
|
|
70
|
+
|
|
71
|
+
// Write an empty BOOTSTRAP.md so the default workspace bootstrap isn't inherited.
|
|
72
|
+
writeFileSync(join(agentDir, 'BOOTSTRAP.md'), '', 'utf-8');
|
|
73
|
+
|
|
74
|
+
entry!.workspace = agentDir;
|
|
75
|
+
entry!.agentDir = agentDir;
|
|
76
|
+
|
|
77
|
+
// Persist config.
|
|
78
|
+
await rt.config.writeConfigFile(cfg);
|
|
79
|
+
|
|
80
|
+
// Update cache.
|
|
81
|
+
hashCache.set(agentId, hash);
|
|
82
|
+
|
|
83
|
+
log?.info?.(`[Whimmy] Agent ${agentId} synced: model=${agentConfig.model} agentDir=${agentDir}`);
|
|
84
|
+
|
|
85
|
+
return cfg;
|
|
86
|
+
}
|
package/src/utils.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { basename } from 'node:path';
|
|
1
3
|
import type { ConnectionInfo, WhimmyConfig, Logger } from './types';
|
|
2
4
|
|
|
3
5
|
const DEFAULT_HOST = 'api.whimmy.ai';
|
|
@@ -176,3 +178,45 @@ export async function retryWithBackoff<T>(
|
|
|
176
178
|
|
|
177
179
|
throw new Error('Retry exhausted');
|
|
178
180
|
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Upload a local file to the Whimmy backend.
|
|
184
|
+
* Returns the uploaded file's public URL.
|
|
185
|
+
*/
|
|
186
|
+
export async function uploadFile(
|
|
187
|
+
filePath: string,
|
|
188
|
+
conn: ConnectionInfo,
|
|
189
|
+
): Promise<{ url: string; fileName: string; mimeType: string }> {
|
|
190
|
+
const protocol = conn.tls ? 'https' : 'http';
|
|
191
|
+
const url = `${protocol}://${conn.host}/files/upload`;
|
|
192
|
+
|
|
193
|
+
const fileBuffer = readFileSync(filePath);
|
|
194
|
+
const fileName = basename(filePath);
|
|
195
|
+
|
|
196
|
+
const form = new FormData();
|
|
197
|
+
form.append('file', new Blob([fileBuffer]), fileName);
|
|
198
|
+
|
|
199
|
+
const resp = await fetch(url, {
|
|
200
|
+
method: 'POST',
|
|
201
|
+
headers: { 'Authorization': `Bearer ${conn.token}` },
|
|
202
|
+
body: form,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
if (!resp.ok) {
|
|
206
|
+
const body = await resp.text();
|
|
207
|
+
throw new Error(`File upload failed (${resp.status}): ${body}`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const data = await resp.json() as {
|
|
211
|
+
id: string;
|
|
212
|
+
url: string;
|
|
213
|
+
originalName: string;
|
|
214
|
+
mimeType: string;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
url: data.url,
|
|
219
|
+
fileName: data.originalName,
|
|
220
|
+
mimeType: data.mimeType,
|
|
221
|
+
};
|
|
222
|
+
}
|