@whimmy-ai/whimmy 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@whimmy-ai/whimmy",
3
- "version": "0.4.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
@@ -12,6 +12,7 @@ import type {
12
12
  HookApprovalRequest,
13
13
  HookReactRequest,
14
14
  HookReadRequest,
15
+ HookAskUserAnswerRequest,
15
16
  ChatChunkPayload,
16
17
  ChatMediaPayload,
17
18
  ChatPresencePayload,
@@ -20,6 +21,8 @@ import type {
20
21
  ChatDeletePayload,
21
22
  ToolLifecyclePayload,
22
23
  ExecApprovalRequestedPayload,
24
+ AskUserQuestionPayload,
25
+ AskUserQuestion,
23
26
  WebhookEvent,
24
27
  ResolvedAccount,
25
28
  GatewayStartContext,
@@ -90,6 +93,11 @@ function sendEvent(ws: WebSocket, event: string, payload: unknown): boolean {
90
93
  /** Pending tool call results: callId → resolve function */
91
94
  const toolResultWaiters = new Map<string, (result: ToolResultPayload) => void>();
92
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
+
93
101
  // ============ History Formatting ============
94
102
 
95
103
  function formatHistoryForAgent(history: HistoryMessage[]): string {
@@ -348,6 +356,60 @@ export function broadcastApprovalRequest(payload: ExecApprovalRequestedPayload):
348
356
  broadcastEvent('exec.approval.requested', payload);
349
357
  }
350
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
+
351
413
  // ============ Actions ============
352
414
 
353
415
  function createWhimmyActions(ws: WebSocket, sessionKey: string, agentId: string) {
@@ -461,6 +523,11 @@ async function connectWebSocket(
461
523
  handleHookRead(request, log);
462
524
  break;
463
525
  }
526
+ case 'hook.ask_user_answer': {
527
+ const request = env.payload as HookAskUserAnswerRequest;
528
+ handleHookAskUserAnswer(request, log);
529
+ break;
530
+ }
464
531
  case 'tool.result': {
465
532
  const result = env.payload as ToolResultPayload;
466
533
  const waiter = toolResultWaiters.get(result.callId);
@@ -832,8 +899,43 @@ export const whimmyPlugin: WhimmyChannelPlugin = {
832
899
  * Called from index.ts during plugin registration.
833
900
  */
834
901
  export function registerWhimmyHooks(api: OpenClawPluginApi): void {
835
- api.on('before_tool_call', (event, ctx) => {
902
+ api.on('before_tool_call', async (event, ctx) => {
836
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.
837
939
  const executionId = randomUUID();
838
940
  const payload: ToolLifecyclePayload = {
839
941
  sessionKey: ctx.sessionKey,
@@ -847,6 +949,9 @@ export function registerWhimmyHooks(api: OpenClawPluginApi): void {
847
949
 
848
950
  api.on('after_tool_call', (event, ctx) => {
849
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
+
850
955
  const eventName = event.error ? 'tool.error' : 'tool.done';
851
956
  const payload: ToolLifecyclePayload = {
852
957
  sessionKey: ctx.sessionKey,
package/src/sync.ts CHANGED
@@ -6,21 +6,23 @@ import type { OpenClawConfig } from 'openclaw/plugin-sdk';
6
6
  import { getWhimmyRuntime } from './runtime';
7
7
  import type { AgentConfig, Logger } from './types';
8
8
 
9
- /** In-memory cache: agentId → hash of { model, systemPrompt }. */
9
+ /** In-memory cache: agentId → hash of full synced config. */
10
10
  const hashCache = new Map<string, string>();
11
11
 
12
12
  function computeHash(agentConfig: AgentConfig): string {
13
13
  const data = JSON.stringify({
14
14
  model: agentConfig.model,
15
15
  systemPrompt: agentConfig.systemPrompt ?? '',
16
+ skills: agentConfig.skills ?? null,
17
+ skillEntries: agentConfig.skillEntries ?? null,
16
18
  });
17
19
  return createHash('sha256').update(data).digest('hex');
18
20
  }
19
21
 
20
22
  /**
21
23
  * 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
+ * model, system prompt, and skills. Uses an in-memory hash cache to skip
25
+ * redundant disk writes when nothing changed.
24
26
  */
25
27
  export async function ensureWhimmyAgent(
26
28
  agentId: string,
@@ -58,6 +60,32 @@ export async function ensureWhimmyAgent(
58
60
  // Set model.
59
61
  entry!.model = agentConfig.model;
60
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
+
61
89
  // Resolve workspace dir and write SOUL.md.
62
90
  const workspace = cfg.agents?.defaults?.workspace
63
91
  ?? join(homedir(), '.openclaw', 'workspace');
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
@@ -65,7 +65,7 @@ export async function exchangePairingCode(
65
65
  tls: boolean = true,
66
66
  ): Promise<ConnectionInfo> {
67
67
  const protocol = tls ? 'https' : 'http';
68
- const url = `${protocol}://${host}/api/v1/openclaw/pair/redeem`;
68
+ const url = `${protocol}://${host}/api/v1/providers/pair/redeem`;
69
69
 
70
70
  const resp = await fetch(url, {
71
71
  method: 'POST',
@@ -143,7 +143,7 @@ export async function resolveConnectionAsync(
143
143
  */
144
144
  export function buildWsUrl(conn: ConnectionInfo): string {
145
145
  const protocol = conn.tls ? 'wss' : 'ws';
146
- 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)}`;
147
147
  }
148
148
 
149
149
  /**
@@ -188,7 +188,7 @@ export async function uploadFile(
188
188
  conn: ConnectionInfo,
189
189
  ): Promise<{ url: string; fileName: string; mimeType: string }> {
190
190
  const protocol = conn.tls ? 'https' : 'http';
191
- const url = `${protocol}://${conn.host}/files/upload`;
191
+ const url = `${protocol}://${conn.host}/api/v1/files/upload`;
192
192
 
193
193
  const fileBuffer = readFileSync(filePath);
194
194
  const fileName = basename(filePath);