@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 +1 -1
- package/src/channel.ts +106 -1
- package/src/sync.ts +31 -3
- package/src/types.ts +43 -0
- package/src/utils.ts +3 -3
package/package.json
CHANGED
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
|
|
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
|
|
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/
|
|
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/
|
|
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);
|