@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 +1 -6
- package/package.json +1 -1
- package/src/channel.ts +195 -26
- package/src/sync.ts +114 -0
- package/src/types.ts +43 -0
- package/src/utils.ts +46 -2
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
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
|
-
//
|
|
130
|
-
const
|
|
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
|
-
|
|
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:
|
|
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 ||
|
|
196
|
+
sessionKey: ctx.SessionKey || sessionKey,
|
|
189
197
|
ctx,
|
|
190
198
|
updateLastRoute: {
|
|
191
|
-
sessionKey:
|
|
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
|
-
|
|
220
|
-
|
|
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
|
|
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
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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
|
|
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
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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/
|
|
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/
|
|
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
|
+
}
|