@whimmy-ai/whimmy 0.3.0 → 0.5.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.5.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,
@@ -11,6 +12,7 @@ import type {
11
12
  HookApprovalRequest,
12
13
  HookReactRequest,
13
14
  HookReadRequest,
15
+ HookAskUserAnswerRequest,
14
16
  ChatChunkPayload,
15
17
  ChatMediaPayload,
16
18
  ChatPresencePayload,
@@ -19,6 +21,8 @@ import type {
19
21
  ChatDeletePayload,
20
22
  ToolLifecyclePayload,
21
23
  ExecApprovalRequestedPayload,
24
+ AskUserQuestionPayload,
25
+ AskUserQuestion,
22
26
  WebhookEvent,
23
27
  ResolvedAccount,
24
28
  GatewayStartContext,
@@ -89,6 +93,11 @@ function sendEvent(ws: WebSocket, event: string, payload: unknown): boolean {
89
93
  /** Pending tool call results: callId → resolve function */
90
94
  const toolResultWaiters = new Map<string, (result: ToolResultPayload) => void>();
91
95
 
96
+ // ============ AskUserQuestion Waiters ============
97
+
98
+ /** Pending user question answers: questionId → resolve function */
99
+ const askUserQuestionWaiters = new Map<string, (answers: Record<string, string>) => void>();
100
+
92
101
  // ============ History Formatting ============
93
102
 
94
103
  function formatHistoryForAgent(history: HistoryMessage[]): string {
@@ -126,15 +135,14 @@ async function handleHookAgent(
126
135
 
127
136
  log?.info?.(`[Whimmy] Inbound: agent=${request.agentId} session=${request.sessionKey} text="${request.message.slice(0, 80)}..."`);
128
137
 
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
- });
138
+ // Sync Whimmy agent config into OpenClaw (model + system prompt).
139
+ const syncedCfg = await ensureWhimmyAgent(request.agentId, request.agentConfig, log);
136
140
 
137
- const storePath = rt.channel.session.resolveStorePath(cfg.session?.store, { agentId: route.agentId });
141
+ // Construct session key directly — no need for resolveAgentRoute.
142
+ const agentId = request.agentId;
143
+ const sessionKey = `agent:${agentId}:direct:${request.sessionKey}`.toLowerCase();
144
+ const mainSessionKey = `agent:${agentId}:main`.toLowerCase();
145
+ const storePath = rt.channel.session.resolveStorePath(syncedCfg.session?.store, { agentId });
138
146
 
139
147
  // Build media fields from attachments (following MS Teams / BlueBubbles pattern).
140
148
  const attachments = request.attachments ?? [];
@@ -159,7 +167,7 @@ async function handleHookAgent(
159
167
  CommandBody: request.message,
160
168
  From: request.sessionKey,
161
169
  To: request.sessionKey,
162
- SessionKey: request.sessionKey,
170
+ SessionKey: sessionKey,
163
171
  AccountId: accountId,
164
172
  ChatType: 'direct',
165
173
  ConversationLabel: `Whimmy ${request.agentId}`,
@@ -185,10 +193,10 @@ async function handleHookAgent(
185
193
  // Record session.
186
194
  await rt.channel.session.recordInboundSession({
187
195
  storePath,
188
- sessionKey: ctx.SessionKey || route.sessionKey,
196
+ sessionKey: ctx.SessionKey || sessionKey,
189
197
  ctx,
190
198
  updateLastRoute: {
191
- sessionKey: route.mainSessionKey,
199
+ sessionKey: mainSessionKey,
192
200
  channel: 'whimmy',
193
201
  to: request.sessionKey,
194
202
  accountId,
@@ -216,13 +224,27 @@ async function handleHookAgent(
216
224
  deliver: async (payload: any) => {
217
225
  // Handle media attachments.
218
226
  const urls: string[] = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
219
- for (const url of urls) {
220
- const fileName = url.split('/').pop()?.split('?')[0] || 'file';
227
+ const connInfo = activeConnections.get(accountId)?.conn;
228
+ for (let url of urls) {
229
+ let fileName = url.split('/').pop()?.split('?')[0] || 'file';
230
+ let mimeType = payload.mimeType || 'application/octet-stream';
231
+ // Upload local files to the backend first.
232
+ if (connInfo && url.startsWith('/')) {
233
+ try {
234
+ const uploaded = await uploadFile(url, connInfo);
235
+ url = uploaded.url;
236
+ fileName = uploaded.fileName;
237
+ mimeType = uploaded.mimeType;
238
+ } catch (err: any) {
239
+ log?.error?.(`[Whimmy] Failed to upload file ${url}: ${err.message}`);
240
+ continue;
241
+ }
242
+ }
221
243
  const media: ChatMediaPayload = {
222
244
  sessionKey: request.sessionKey,
223
245
  agentId: request.agentId,
224
246
  mediaUrl: url,
225
- mimeType: payload.mimeType || 'application/octet-stream',
247
+ mimeType,
226
248
  fileName,
227
249
  audioAsVoice: payload.audioAsVoice ?? false,
228
250
  };
@@ -236,7 +258,7 @@ async function handleHookAgent(
236
258
 
237
259
  await rt.channel.reply.dispatchReplyFromConfig({
238
260
  ctx,
239
- cfg,
261
+ cfg: syncedCfg,
240
262
  dispatcher,
241
263
  replyOptions: {
242
264
  ...replyOptions,
@@ -334,6 +356,60 @@ export function broadcastApprovalRequest(payload: ExecApprovalRequestedPayload):
334
356
  broadcastEvent('exec.approval.requested', payload);
335
357
  }
336
358
 
359
+ /** Forward an ask_user_question event to all connected Whimmy backends. */
360
+ export function broadcastAskUserQuestion(payload: AskUserQuestionPayload): void {
361
+ broadcastEvent('ask_user_question', payload);
362
+ }
363
+
364
+ /**
365
+ * Send a question to the user and wait for their answer.
366
+ * Returns the answers map keyed by question text → selected label(s).
367
+ */
368
+ export function askUserQuestion(
369
+ sessionKey: string,
370
+ agentId: string,
371
+ questions: AskUserQuestion[],
372
+ timeoutMs = 120_000,
373
+ ): Promise<Record<string, string>> {
374
+ const questionId = randomUUID();
375
+
376
+ return new Promise<Record<string, string>>((resolve, reject) => {
377
+ const timer = setTimeout(() => {
378
+ askUserQuestionWaiters.delete(questionId);
379
+ reject(new Error(`AskUserQuestion timed out after ${timeoutMs}ms (questionId=${questionId})`));
380
+ }, timeoutMs);
381
+
382
+ askUserQuestionWaiters.set(questionId, (answers) => {
383
+ clearTimeout(timer);
384
+ resolve(answers);
385
+ });
386
+
387
+ const payload: AskUserQuestionPayload = {
388
+ sessionKey,
389
+ agentId,
390
+ questionId,
391
+ questions,
392
+ };
393
+ broadcastAskUserQuestion(payload);
394
+ });
395
+ }
396
+
397
+ /** Handle inbound hook.ask_user_answer from the backend. */
398
+ function handleHookAskUserAnswer(
399
+ request: HookAskUserAnswerRequest,
400
+ log?: Logger,
401
+ ): void {
402
+ log?.info?.(`[Whimmy] AskUserAnswer: questionId=${request.questionId}`);
403
+
404
+ const waiter = askUserQuestionWaiters.get(request.questionId);
405
+ if (waiter) {
406
+ waiter(request.answers);
407
+ askUserQuestionWaiters.delete(request.questionId);
408
+ } else {
409
+ log?.warn?.(`[Whimmy] No waiter for ask_user_answer questionId=${request.questionId} (expired or unknown)`);
410
+ }
411
+ }
412
+
337
413
  // ============ Actions ============
338
414
 
339
415
  function createWhimmyActions(ws: WebSocket, sessionKey: string, agentId: string) {
@@ -357,7 +433,7 @@ function createWhimmyActions(ws: WebSocket, sessionKey: string, agentId: string)
357
433
 
358
434
  // Track active connections per account to prevent duplicate connections
359
435
  // when the framework retries startAccount.
360
- const activeConnections = new Map<string, { ws: WebSocket; stopFn: () => void }>();
436
+ const activeConnections = new Map<string, { ws: WebSocket; conn: ConnectionInfo; stopFn: () => void }>();
361
437
 
362
438
  async function connectWebSocket(
363
439
  conn: ConnectionInfo,
@@ -447,6 +523,11 @@ async function connectWebSocket(
447
523
  handleHookRead(request, log);
448
524
  break;
449
525
  }
526
+ case 'hook.ask_user_answer': {
527
+ const request = env.payload as HookAskUserAnswerRequest;
528
+ handleHookAskUserAnswer(request, log);
529
+ break;
530
+ }
450
531
  case 'tool.result': {
451
532
  const result = env.payload as ToolResultPayload;
452
533
  const waiter = toolResultWaiters.get(result.callId);
@@ -504,7 +585,7 @@ async function connectWebSocket(
504
585
  log?.info?.(`[Whimmy][${accountId}] Stopped`);
505
586
  };
506
587
 
507
- const result = { ws, stopFn };
588
+ const result = { ws, conn, stopFn };
508
589
  activeConnections.set(accountId, result);
509
590
  return result;
510
591
  }
@@ -599,6 +680,18 @@ export const whimmyPlugin: WhimmyChannelPlugin = {
599
680
  const actions = createWhimmyActions(conn.ws, sessionKey, agentId);
600
681
 
601
682
  switch (action) {
683
+ case 'send': {
684
+ const text = typeof params.text === 'string' ? params.text : '';
685
+ if (!text) return { ok: false, error: 'Missing text' };
686
+ const chunk: ChatChunkPayload = {
687
+ sessionKey,
688
+ agentId,
689
+ content: text,
690
+ done: true,
691
+ };
692
+ sendEvent(conn.ws, 'chat.done', chunk);
693
+ return { ok: true };
694
+ }
602
695
  case 'react': {
603
696
  const messageId = typeof params.messageId === 'string' ? params.messageId : '';
604
697
  const emoji = typeof params.emoji === 'string' ? params.emoji : '';
@@ -620,18 +713,46 @@ export const whimmyPlugin: WhimmyChannelPlugin = {
620
713
  return { ok: true };
621
714
  }
622
715
  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';
716
+ let mediaUrl = typeof params.mediaUrl === 'string' ? params.mediaUrl : '';
717
+ let fileName = typeof params.fileName === 'string' ? params.fileName : '';
718
+ let mimeType = typeof params.mimeType === 'string' ? params.mimeType : '';
719
+ const filePath = typeof params.filePath === 'string' ? params.filePath : '';
720
+
721
+ // If a local file path is provided, upload it to the backend first.
722
+ if (filePath && !mediaUrl) {
723
+ try {
724
+ const uploaded = await uploadFile(filePath, conn.conn);
725
+ mediaUrl = uploaded.url;
726
+ if (!fileName) fileName = uploaded.fileName;
727
+ if (!mimeType) mimeType = uploaded.mimeType;
728
+ } catch (uploadErr: any) {
729
+ return { ok: false, error: `File upload failed: ${uploadErr.message}` };
730
+ }
731
+ }
732
+
733
+ if (!mediaUrl) return { ok: false, error: 'Missing mediaUrl or filePath' };
734
+ if (!fileName) fileName = mediaUrl.split('/').pop()?.split('?')[0] || 'file';
735
+ if (!mimeType) mimeType = 'application/octet-stream';
736
+
626
737
  const media: ChatMediaPayload = {
627
738
  sessionKey,
628
739
  agentId,
629
740
  mediaUrl,
630
- mimeType: typeof params.mimeType === 'string' ? params.mimeType : 'application/octet-stream',
741
+ mimeType,
631
742
  fileName,
632
743
  audioAsVoice: params.audioAsVoice === true,
633
744
  };
634
745
  sendEvent(conn.ws, 'chat.media', media);
746
+ // Send caption as a follow-up text message if provided.
747
+ if (typeof params.caption === 'string' && params.caption) {
748
+ const caption: ChatChunkPayload = {
749
+ sessionKey,
750
+ agentId,
751
+ content: params.caption,
752
+ done: true,
753
+ };
754
+ sendEvent(conn.ws, 'chat.done', caption);
755
+ }
635
756
  return { ok: true };
636
757
  }
637
758
  default:
@@ -648,9 +769,19 @@ export const whimmyPlugin: WhimmyChannelPlugin = {
648
769
  return { ok: true as const, to: to.trim() };
649
770
  },
650
771
  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.
772
+ const id = accountId ?? 'default';
773
+ const conn = activeConnections.get(id);
774
+ if (!conn || conn.ws.readyState !== WebSocket.OPEN) {
775
+ log?.warn?.(`[Whimmy] sendText: not connected (account=${id})`);
776
+ return { channel: 'whimmy', messageId: randomUUID() };
777
+ }
778
+ const chunk: ChatChunkPayload = {
779
+ sessionKey: to,
780
+ agentId: 'default',
781
+ content: text,
782
+ done: true,
783
+ };
784
+ sendEvent(conn.ws, 'chat.done', chunk);
654
785
  log?.debug?.(`[Whimmy] sendText: to=${to} text=${text.slice(0, 80)}`);
655
786
  return {
656
787
  channel: 'whimmy',
@@ -768,8 +899,43 @@ export const whimmyPlugin: WhimmyChannelPlugin = {
768
899
  * Called from index.ts during plugin registration.
769
900
  */
770
901
  export function registerWhimmyHooks(api: OpenClawPluginApi): void {
771
- api.on('before_tool_call', (event, ctx) => {
902
+ api.on('before_tool_call', async (event, ctx) => {
772
903
  if (!ctx.sessionKey) return;
904
+
905
+ // Intercept AskUserQuestion: forward to Whimmy UI and wait for answer.
906
+ if (event.toolName === 'AskUserQuestion' || event.toolName === 'ask_user_question') {
907
+ const questions = (event.params?.questions ?? []) as AskUserQuestion[];
908
+ if (questions.length === 0) return;
909
+
910
+ // Extract sessionKey — strip the "agent:{agentId}:direct:" prefix to get
911
+ // the original Whimmy session key.
912
+ const parts = ctx.sessionKey.split(':');
913
+ const whimmySessionKey = parts.length >= 4 ? parts.slice(3).join(':') : ctx.sessionKey;
914
+
915
+ try {
916
+ const answers = await askUserQuestion(
917
+ whimmySessionKey,
918
+ ctx.agentId || 'default',
919
+ questions,
920
+ );
921
+
922
+ // Return modified params with the user's answers filled in.
923
+ return {
924
+ params: {
925
+ ...event.params,
926
+ answers,
927
+ },
928
+ };
929
+ } catch (err: any) {
930
+ api.logger?.warn?.(`[Whimmy] AskUserQuestion failed: ${err.message}`);
931
+ return {
932
+ block: true,
933
+ blockReason: `User did not respond: ${err.message}`,
934
+ };
935
+ }
936
+ }
937
+
938
+ // Default: broadcast tool.start lifecycle event.
773
939
  const executionId = randomUUID();
774
940
  const payload: ToolLifecyclePayload = {
775
941
  sessionKey: ctx.sessionKey,
@@ -783,6 +949,9 @@ export function registerWhimmyHooks(api: OpenClawPluginApi): void {
783
949
 
784
950
  api.on('after_tool_call', (event, ctx) => {
785
951
  if (!ctx.sessionKey) return;
952
+ // Skip lifecycle events for AskUserQuestion — already handled.
953
+ if (event.toolName === 'AskUserQuestion' || event.toolName === 'ask_user_question') return;
954
+
786
955
  const eventName = event.error ? 'tool.error' : 'tool.done';
787
956
  const payload: ToolLifecyclePayload = {
788
957
  sessionKey: ctx.sessionKey,
package/src/sync.ts ADDED
@@ -0,0 +1,114 @@
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 full synced config. */
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
+ skills: agentConfig.skills ?? null,
17
+ skillEntries: agentConfig.skillEntries ?? null,
18
+ });
19
+ return createHash('sha256').update(data).digest('hex');
20
+ }
21
+
22
+ /**
23
+ * Ensure the Whimmy agent exists in OpenClaw's config with the correct
24
+ * model, system prompt, and skills. Uses an in-memory hash cache to skip
25
+ * redundant disk writes when nothing changed.
26
+ */
27
+ export async function ensureWhimmyAgent(
28
+ agentId: string,
29
+ agentConfig: AgentConfig,
30
+ log?: Logger,
31
+ ): Promise<OpenClawConfig> {
32
+ const rt = getWhimmyRuntime();
33
+ const hash = computeHash(agentConfig);
34
+
35
+ // Fast path: nothing changed since last sync.
36
+ if (hashCache.get(agentId) === hash) {
37
+ log?.debug?.(`[Whimmy] Agent config unchanged for ${agentId}, skipping sync`);
38
+ return rt.config.loadConfig();
39
+ }
40
+
41
+ log?.info?.(`[Whimmy] Syncing agent config for ${agentId}`);
42
+
43
+ const cfg = rt.config.loadConfig();
44
+
45
+ // Ensure agents.list exists.
46
+ if (!cfg.agents) {
47
+ (cfg as any).agents = {};
48
+ }
49
+ if (!cfg.agents!.list) {
50
+ cfg.agents!.list = [];
51
+ }
52
+
53
+ // Find or create agent entry.
54
+ let entry = cfg.agents!.list!.find((a) => a.id === agentId);
55
+ if (!entry) {
56
+ cfg.agents!.list!.push({ id: agentId });
57
+ entry = cfg.agents!.list![cfg.agents!.list!.length - 1];
58
+ }
59
+
60
+ // Set model.
61
+ entry!.model = agentConfig.model;
62
+
63
+ // Set per-agent skill allowlist.
64
+ if (agentConfig.skills !== undefined) {
65
+ entry!.skills = agentConfig.skills;
66
+ log?.info?.(`[Whimmy] Agent ${agentId} skills: [${agentConfig.skills.join(', ')}]`);
67
+ }
68
+
69
+ // Sync global skill entries (enable/disable, API keys, env vars).
70
+ if (agentConfig.skillEntries && Object.keys(agentConfig.skillEntries).length > 0) {
71
+ if (!cfg.skills) {
72
+ (cfg as any).skills = {};
73
+ }
74
+ if (!cfg.skills!.entries) {
75
+ cfg.skills!.entries = {};
76
+ }
77
+
78
+ for (const [skillName, skillConfig] of Object.entries(agentConfig.skillEntries)) {
79
+ cfg.skills!.entries![skillName] = {
80
+ ...cfg.skills!.entries![skillName],
81
+ ...skillConfig,
82
+ };
83
+ }
84
+
85
+ const names = Object.keys(agentConfig.skillEntries);
86
+ log?.info?.(`[Whimmy] Synced skill entries: [${names.join(', ')}]`);
87
+ }
88
+
89
+ // Resolve workspace dir and write SOUL.md.
90
+ const workspace = cfg.agents?.defaults?.workspace
91
+ ?? join(homedir(), '.openclaw', 'workspace');
92
+ const agentDir = join(workspace, 'agents', agentId);
93
+
94
+ mkdirSync(agentDir, { recursive: true });
95
+
96
+ // Write SOUL.md with the system prompt (or empty to clear a previous one).
97
+ writeFileSync(join(agentDir, 'SOUL.md'), agentConfig.systemPrompt ?? '', 'utf-8');
98
+
99
+ // Write an empty BOOTSTRAP.md so the default workspace bootstrap isn't inherited.
100
+ writeFileSync(join(agentDir, 'BOOTSTRAP.md'), '', 'utf-8');
101
+
102
+ entry!.workspace = agentDir;
103
+ entry!.agentDir = agentDir;
104
+
105
+ // Persist config.
106
+ await rt.config.writeConfigFile(cfg);
107
+
108
+ // Update cache.
109
+ hashCache.set(agentId, hash);
110
+
111
+ log?.info?.(`[Whimmy] Agent ${agentId} synced: model=${agentConfig.model} agentDir=${agentDir}`);
112
+
113
+ return cfg;
114
+ }
package/src/types.ts CHANGED
@@ -50,6 +50,18 @@ export interface AgentConfig {
50
50
  systemPrompt?: string;
51
51
  mcpTools?: string[];
52
52
  proactivity?: string;
53
+ /** Per-agent skill allowlist. Omit = all skills; empty array = none. */
54
+ skills?: string[];
55
+ /** Global skill entries to sync (enable/disable, API keys, env vars). */
56
+ skillEntries?: Record<string, SkillEntryConfig>;
57
+ }
58
+
59
+ /** SkillEntryConfig — per-skill configuration synced from Whimmy. */
60
+ export interface SkillEntryConfig {
61
+ enabled?: boolean;
62
+ apiKey?: string;
63
+ env?: Record<string, string>;
64
+ config?: Record<string, unknown>;
53
65
  }
54
66
 
55
67
  /** HookAttachment describes a file attached to a user message. */
@@ -193,6 +205,37 @@ export interface HookReadRequest {
193
205
  messageId?: string;
194
206
  }
195
207
 
208
+ // ============ AskUserQuestion Protocol ============
209
+
210
+ /** Option in a multiple-choice question. */
211
+ export interface AskUserQuestionOption {
212
+ label: string;
213
+ description: string;
214
+ markdown?: string;
215
+ }
216
+
217
+ /** A single question with options. */
218
+ export interface AskUserQuestion {
219
+ question: string;
220
+ header: string;
221
+ options: AskUserQuestionOption[];
222
+ multiSelect: boolean;
223
+ }
224
+
225
+ /** AskUserQuestionPayload — sent to backend when agent needs user input. */
226
+ export interface AskUserQuestionPayload {
227
+ sessionKey: string;
228
+ agentId: string;
229
+ questionId: string;
230
+ questions: AskUserQuestion[];
231
+ }
232
+
233
+ /** HookAskUserAnswerRequest — backend sends this with the user's answers. */
234
+ export interface HookAskUserAnswerRequest {
235
+ questionId: string;
236
+ answers: Record<string, string>;
237
+ }
238
+
196
239
  /** ExecApprovalRequestedPayload — approval request sent back to backend. */
197
240
  export interface ExecApprovalRequestedPayload {
198
241
  sessionKey: string;
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';
@@ -63,7 +65,7 @@ export async function exchangePairingCode(
63
65
  tls: boolean = true,
64
66
  ): Promise<ConnectionInfo> {
65
67
  const protocol = tls ? 'https' : 'http';
66
- const url = `${protocol}://${host}/api/v1/openclaw/pair/redeem`;
68
+ const url = `${protocol}://${host}/api/v1/providers/pair/redeem`;
67
69
 
68
70
  const resp = await fetch(url, {
69
71
  method: 'POST',
@@ -141,7 +143,7 @@ export async function resolveConnectionAsync(
141
143
  */
142
144
  export function buildWsUrl(conn: ConnectionInfo): string {
143
145
  const protocol = conn.tls ? 'wss' : 'ws';
144
- return `${protocol}://${conn.host}/api/v1/openclaw/ws?token=${encodeURIComponent(conn.token)}`;
146
+ return `${protocol}://${conn.host}/api/v1/providers/ws?token=${encodeURIComponent(conn.token)}`;
145
147
  }
146
148
 
147
149
  /**
@@ -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}/api/v1/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
+ }