@whimmy-ai/whimmy 0.2.1 → 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
@@ -1,5 +1,5 @@
1
1
  import type { OpenClawPluginApi } from 'openclaw/plugin-sdk';
2
- import { whimmyPlugin, registerWhimmyHooks } from './src/channel';
2
+ import { whimmyPlugin, registerWhimmyHooks, setApprovalManager, broadcastApprovalRequest } from './src/channel';
3
3
  import { setWhimmyRuntime } from './src/runtime';
4
4
  import { registerWhimmyCli } from './src/setup';
5
5
 
@@ -13,6 +13,18 @@ const plugin = {
13
13
  api.registerChannel({ plugin: whimmyPlugin });
14
14
  registerWhimmyCli(api);
15
15
  registerWhimmyHooks(api);
16
+
17
+ // Register a gateway method that captures the ExecApprovalManager reference.
18
+ // The manager is only accessible via GatewayRequestHandlerOptions.context,
19
+ // so we use this method as the capture point.
20
+ // The backend should call this method once after connecting to the gateway.
21
+ api.registerGatewayMethod('whimmy.approval.init', (opts) => {
22
+ const manager = opts.context.execApprovalManager;
23
+ if (manager) {
24
+ setApprovalManager(manager);
25
+ }
26
+ opts.respond(true, { ok: true });
27
+ });
16
28
  },
17
29
  };
18
30
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@whimmy-ai/whimmy",
3
- "version": "0.2.1",
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,
@@ -31,6 +32,27 @@ import type {
31
32
  AgentInfo,
32
33
  } from './types';
33
34
 
35
+ // ============ Exec Approval Manager Singleton ============
36
+
37
+ /**
38
+ * Minimal interface matching ExecApprovalManager.resolve().
39
+ * The full class isn't exported from openclaw/plugin-sdk's barrel,
40
+ * so we type just the method we need.
41
+ */
42
+ interface ApprovalManagerLike {
43
+ resolve(recordId: string, decision: 'allow-once' | 'allow-always' | 'deny', resolvedBy?: string | null): boolean;
44
+ }
45
+
46
+ let approvalManager: ApprovalManagerLike | null = null;
47
+
48
+ export function setApprovalManager(manager: ApprovalManagerLike): void {
49
+ approvalManager = manager;
50
+ }
51
+
52
+ export function getApprovalManager(): ApprovalManagerLike | null {
53
+ return approvalManager;
54
+ }
55
+
34
56
  // ============ Config Helpers ============
35
57
 
36
58
  function getConfig(cfg: OpenClawConfig, accountId?: string): WhimmyConfig {
@@ -105,15 +127,14 @@ async function handleHookAgent(
105
127
 
106
128
  log?.info?.(`[Whimmy] Inbound: agent=${request.agentId} session=${request.sessionKey} text="${request.message.slice(0, 80)}..."`);
107
129
 
108
- // Route to the correct OpenClaw agent.
109
- const route = rt.channel.routing.resolveAgentRoute({
110
- cfg,
111
- channel: 'whimmy',
112
- accountId,
113
- peer: { kind: 'direct', id: request.sessionKey },
114
- });
130
+ // Sync Whimmy agent config into OpenClaw (model + system prompt).
131
+ const syncedCfg = await ensureWhimmyAgent(request.agentId, request.agentConfig, log);
115
132
 
116
- 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 });
117
138
 
118
139
  // Build media fields from attachments (following MS Teams / BlueBubbles pattern).
119
140
  const attachments = request.attachments ?? [];
@@ -138,7 +159,7 @@ async function handleHookAgent(
138
159
  CommandBody: request.message,
139
160
  From: request.sessionKey,
140
161
  To: request.sessionKey,
141
- SessionKey: request.sessionKey,
162
+ SessionKey: sessionKey,
142
163
  AccountId: accountId,
143
164
  ChatType: 'direct',
144
165
  ConversationLabel: `Whimmy ${request.agentId}`,
@@ -164,10 +185,10 @@ async function handleHookAgent(
164
185
  // Record session.
165
186
  await rt.channel.session.recordInboundSession({
166
187
  storePath,
167
- sessionKey: ctx.SessionKey || route.sessionKey,
188
+ sessionKey: ctx.SessionKey || sessionKey,
168
189
  ctx,
169
190
  updateLastRoute: {
170
- sessionKey: route.mainSessionKey,
191
+ sessionKey: mainSessionKey,
171
192
  channel: 'whimmy',
172
193
  to: request.sessionKey,
173
194
  accountId,
@@ -195,13 +216,27 @@ async function handleHookAgent(
195
216
  deliver: async (payload: any) => {
196
217
  // Handle media attachments.
197
218
  const urls: string[] = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
198
- for (const url of urls) {
199
- 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
+ }
200
235
  const media: ChatMediaPayload = {
201
236
  sessionKey: request.sessionKey,
202
237
  agentId: request.agentId,
203
238
  mediaUrl: url,
204
- mimeType: payload.mimeType || 'application/octet-stream',
239
+ mimeType,
205
240
  fileName,
206
241
  audioAsVoice: payload.audioAsVoice ?? false,
207
242
  };
@@ -215,7 +250,7 @@ async function handleHookAgent(
215
250
 
216
251
  await rt.channel.reply.dispatchReplyFromConfig({
217
252
  ctx,
218
- cfg,
253
+ cfg: syncedCfg,
219
254
  dispatcher,
220
255
  replyOptions: {
221
256
  ...replyOptions,
@@ -264,14 +299,22 @@ async function handleHookApproval(
264
299
  request: HookApprovalRequest,
265
300
  log?: Logger,
266
301
  ): Promise<void> {
267
- const rt = getWhimmyRuntime();
268
-
269
302
  log?.info?.(`[Whimmy] Approval: execution=${request.executionId} approved=${request.approved}`);
270
303
 
271
- // TODO: Route approval resolution back into OpenClaw's execution engine.
272
- // This depends on how OpenClaw exposes approval resolution to channel plugins.
273
- // For now, log itthe exact API will depend on the OpenClaw SDK version.
274
- log?.debug?.(`[Whimmy] Approval resolution for ${request.executionId}: approved=${request.approved}, reason=${request.reason}`);
304
+ const manager = getApprovalManager();
305
+ if (!manager) {
306
+ log?.warn?.(`[Whimmy] ExecApprovalManager not yet capturedcannot resolve execution ${request.executionId}`);
307
+ return;
308
+ }
309
+
310
+ const decision = request.approved ? 'allow-once' as const : 'deny' as const;
311
+ const resolved = manager.resolve(request.executionId, decision, 'whimmy');
312
+
313
+ if (resolved) {
314
+ log?.info?.(`[Whimmy] Resolved execution ${request.executionId} → ${decision}`);
315
+ } else {
316
+ log?.warn?.(`[Whimmy] Failed to resolve execution ${request.executionId} (expired or unknown)`);
317
+ }
275
318
  }
276
319
 
277
320
  // ============ Inbound Event Handlers ============
@@ -300,6 +343,11 @@ function broadcastEvent(event: string, payload: unknown): void {
300
343
  }
301
344
  }
302
345
 
346
+ /** Forward an exec.approval.requested event to all connected Whimmy backends. */
347
+ export function broadcastApprovalRequest(payload: ExecApprovalRequestedPayload): void {
348
+ broadcastEvent('exec.approval.requested', payload);
349
+ }
350
+
303
351
  // ============ Actions ============
304
352
 
305
353
  function createWhimmyActions(ws: WebSocket, sessionKey: string, agentId: string) {
@@ -323,7 +371,7 @@ function createWhimmyActions(ws: WebSocket, sessionKey: string, agentId: string)
323
371
 
324
372
  // Track active connections per account to prevent duplicate connections
325
373
  // when the framework retries startAccount.
326
- const activeConnections = new Map<string, { ws: WebSocket; stopFn: () => void }>();
374
+ const activeConnections = new Map<string, { ws: WebSocket; conn: ConnectionInfo; stopFn: () => void }>();
327
375
 
328
376
  async function connectWebSocket(
329
377
  conn: ConnectionInfo,
@@ -470,7 +518,7 @@ async function connectWebSocket(
470
518
  log?.info?.(`[Whimmy][${accountId}] Stopped`);
471
519
  };
472
520
 
473
- const result = { ws, stopFn };
521
+ const result = { ws, conn, stopFn };
474
522
  activeConnections.set(accountId, result);
475
523
  return result;
476
524
  }
@@ -565,6 +613,18 @@ export const whimmyPlugin: WhimmyChannelPlugin = {
565
613
  const actions = createWhimmyActions(conn.ws, sessionKey, agentId);
566
614
 
567
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
+ }
568
628
  case 'react': {
569
629
  const messageId = typeof params.messageId === 'string' ? params.messageId : '';
570
630
  const emoji = typeof params.emoji === 'string' ? params.emoji : '';
@@ -586,18 +646,46 @@ export const whimmyPlugin: WhimmyChannelPlugin = {
586
646
  return { ok: true };
587
647
  }
588
648
  case 'sendAttachment': {
589
- const mediaUrl = typeof params.mediaUrl === 'string' ? params.mediaUrl : '';
590
- if (!mediaUrl) return { ok: false, error: 'Missing mediaUrl' };
591
- 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
+
592
670
  const media: ChatMediaPayload = {
593
671
  sessionKey,
594
672
  agentId,
595
673
  mediaUrl,
596
- mimeType: typeof params.mimeType === 'string' ? params.mimeType : 'application/octet-stream',
674
+ mimeType,
597
675
  fileName,
598
676
  audioAsVoice: params.audioAsVoice === true,
599
677
  };
600
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
+ }
601
689
  return { ok: true };
602
690
  }
603
691
  default:
@@ -614,9 +702,19 @@ export const whimmyPlugin: WhimmyChannelPlugin = {
614
702
  return { ok: true as const, to: to.trim() };
615
703
  },
616
704
  sendText: async ({ cfg, to, text, accountId, log }: any) => {
617
- // Outbound from OpenClaw CLI → send as chat.done through the WS.
618
- // This is handled by the gateway connection, not a standalone HTTP call.
619
- // 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);
620
718
  log?.debug?.(`[Whimmy] sendText: to=${to} text=${text.slice(0, 80)}`);
621
719
  return {
622
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
+ }