@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@whimmy-ai/whimmy",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Whimmy channel plugin for OpenClaw",
5
5
  "main": "index.ts",
6
6
  "type": "module",
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
- // Route to the correct OpenClaw agent.
130
- const route = rt.channel.routing.resolveAgentRoute({
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
- const storePath = rt.channel.session.resolveStorePath(cfg.session?.store, { agentId: route.agentId });
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: request.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 || route.sessionKey,
188
+ sessionKey: ctx.SessionKey || sessionKey,
189
189
  ctx,
190
190
  updateLastRoute: {
191
- sessionKey: route.mainSessionKey,
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
- for (const url of urls) {
220
- const fileName = url.split('/').pop()?.split('?')[0] || 'file';
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: payload.mimeType || 'application/octet-stream',
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
- const mediaUrl = typeof params.mediaUrl === 'string' ? params.mediaUrl : '';
624
- if (!mediaUrl) return { ok: false, error: 'Missing mediaUrl' };
625
- const fileName = typeof params.fileName === 'string' ? params.fileName : mediaUrl.split('/').pop()?.split('?')[0] || 'file';
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: typeof params.mimeType === 'string' ? params.mimeType : 'application/octet-stream',
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
- // Outbound from OpenClaw CLI → send as chat.done through the WS.
652
- // This is handled by the gateway connection, not a standalone HTTP call.
653
- // For now, log it. The gateway dispatchReply handles the normal flow.
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
+ }