@whimmy-ai/whimmy 0.4.0 → 0.6.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 -13
- package/package.json +1 -1
- package/src/channel.ts +334 -34
- package/src/sync.ts +132 -5
- package/src/types.ts +93 -0
- package/src/utils.ts +3 -3
package/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { OpenClawPluginApi } from 'openclaw/plugin-sdk';
|
|
2
|
-
import { whimmyPlugin, registerWhimmyHooks
|
|
2
|
+
import { whimmyPlugin, registerWhimmyHooks } from './src/channel';
|
|
3
3
|
import { setWhimmyRuntime } from './src/runtime';
|
|
4
4
|
import { registerWhimmyCli } from './src/setup';
|
|
5
5
|
|
|
@@ -13,18 +13,6 @@ 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
|
-
});
|
|
28
16
|
},
|
|
29
17
|
};
|
|
30
18
|
|
package/package.json
CHANGED
package/src/channel.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import WebSocket from 'ws';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
3
6
|
import type { OpenClawConfig, OpenClawPluginApi } from 'openclaw/plugin-sdk';
|
|
4
7
|
import { getWhimmyRuntime } from './runtime';
|
|
5
8
|
import { resolveConnection, resolveConnectionAsync, buildWsUrl, isConfigured as isConfiguredUtil, uploadFile } from './utils';
|
|
6
|
-
import { ensureWhimmyAgent } from './sync';
|
|
9
|
+
import { ensureWhimmyAgent, collectChangedMemoryFiles } from './sync';
|
|
7
10
|
import type {
|
|
8
11
|
WhimmyConfig,
|
|
9
12
|
WhimmyChannelPlugin,
|
|
@@ -12,6 +15,7 @@ import type {
|
|
|
12
15
|
HookApprovalRequest,
|
|
13
16
|
HookReactRequest,
|
|
14
17
|
HookReadRequest,
|
|
18
|
+
HookAskUserAnswerRequest,
|
|
15
19
|
ChatChunkPayload,
|
|
16
20
|
ChatMediaPayload,
|
|
17
21
|
ChatPresencePayload,
|
|
@@ -20,6 +24,8 @@ import type {
|
|
|
20
24
|
ChatDeletePayload,
|
|
21
25
|
ToolLifecyclePayload,
|
|
22
26
|
ExecApprovalRequestedPayload,
|
|
27
|
+
AskUserQuestionPayload,
|
|
28
|
+
AskUserQuestion,
|
|
23
29
|
WebhookEvent,
|
|
24
30
|
ResolvedAccount,
|
|
25
31
|
GatewayStartContext,
|
|
@@ -30,28 +36,14 @@ import type {
|
|
|
30
36
|
ToolResultPayload,
|
|
31
37
|
HistoryMessage,
|
|
32
38
|
AgentInfo,
|
|
39
|
+
AgentConfig,
|
|
40
|
+
AgentMemorySyncPayload,
|
|
33
41
|
} from './types';
|
|
34
42
|
|
|
35
|
-
// ============
|
|
43
|
+
// ============ Per-Agent Config Cache ============
|
|
36
44
|
|
|
37
|
-
/**
|
|
38
|
-
|
|
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
|
-
}
|
|
45
|
+
/** Stores the latest AgentConfig per agentId so hooks can read it. */
|
|
46
|
+
const agentConfigCache = new Map<string, AgentConfig>();
|
|
55
47
|
|
|
56
48
|
// ============ Config Helpers ============
|
|
57
49
|
|
|
@@ -90,6 +82,34 @@ function sendEvent(ws: WebSocket, event: string, payload: unknown): boolean {
|
|
|
90
82
|
/** Pending tool call results: callId → resolve function */
|
|
91
83
|
const toolResultWaiters = new Map<string, (result: ToolResultPayload) => void>();
|
|
92
84
|
|
|
85
|
+
// ============ Approval Waiters ============
|
|
86
|
+
|
|
87
|
+
/** Pending approval decisions: executionId → resolve function */
|
|
88
|
+
const approvalWaiters = new Map<string, (approved: boolean) => void>();
|
|
89
|
+
|
|
90
|
+
/** Session-level approval memory: agentId → Set of already-approved tool names */
|
|
91
|
+
const sessionApprovals = new Map<string, Set<string>>();
|
|
92
|
+
|
|
93
|
+
// ============ AskUserQuestion Waiters ============
|
|
94
|
+
|
|
95
|
+
/** Pending user question answers: questionId → resolve function */
|
|
96
|
+
const askUserQuestionWaiters = new Map<string, (answers: Record<string, string>) => void>();
|
|
97
|
+
|
|
98
|
+
// ============ Model Context Limits ============
|
|
99
|
+
|
|
100
|
+
/** Resolve the max context window size for a given model identifier. */
|
|
101
|
+
function resolveMaxContextTokens(model: string): number {
|
|
102
|
+
const m = model.toLowerCase();
|
|
103
|
+
if (m.includes('opus')) return 200_000;
|
|
104
|
+
if (m.includes('haiku')) return 200_000;
|
|
105
|
+
if (m.includes('sonnet')) return 200_000;
|
|
106
|
+
if (m.includes('gpt-4o')) return 128_000;
|
|
107
|
+
if (m.includes('gpt-4')) return 128_000;
|
|
108
|
+
if (m.includes('o1') || m.includes('o3') || m.includes('o4')) return 200_000;
|
|
109
|
+
// Default fallback.
|
|
110
|
+
return 200_000;
|
|
111
|
+
}
|
|
112
|
+
|
|
93
113
|
// ============ History Formatting ============
|
|
94
114
|
|
|
95
115
|
function formatHistoryForAgent(history: HistoryMessage[]): string {
|
|
@@ -127,6 +147,9 @@ async function handleHookAgent(
|
|
|
127
147
|
|
|
128
148
|
log?.info?.(`[Whimmy] Inbound: agent=${request.agentId} session=${request.sessionKey} text="${request.message.slice(0, 80)}..."`);
|
|
129
149
|
|
|
150
|
+
// Cache agent config so hooks can read it later.
|
|
151
|
+
agentConfigCache.set(request.agentId, request.agentConfig);
|
|
152
|
+
|
|
130
153
|
// Sync Whimmy agent config into OpenClaw (model + system prompt).
|
|
131
154
|
const syncedCfg = await ensureWhimmyAgent(request.agentId, request.agentConfig, log);
|
|
132
155
|
|
|
@@ -278,6 +301,27 @@ async function handleHookAgent(
|
|
|
278
301
|
log?.error?.(`[Whimmy] dispatch error: ${dispatchErr.message}`);
|
|
279
302
|
}
|
|
280
303
|
|
|
304
|
+
// Sync changed memory files back to the backend (non-fatal).
|
|
305
|
+
try {
|
|
306
|
+
const workspace = syncedCfg.agents?.defaults?.workspace
|
|
307
|
+
?? join(homedir(), '.openclaw', 'workspace');
|
|
308
|
+
const agentDir = join(workspace, 'agents', request.agentId);
|
|
309
|
+
const changedFiles = collectChangedMemoryFiles(request.agentId, agentDir, log);
|
|
310
|
+
|
|
311
|
+
if (changedFiles) {
|
|
312
|
+
const memoryPayload: AgentMemorySyncPayload = {
|
|
313
|
+
sessionKey: request.sessionKey,
|
|
314
|
+
agentId: request.agentId,
|
|
315
|
+
files: changedFiles,
|
|
316
|
+
};
|
|
317
|
+
sendEvent(ws, 'agent.memory_sync', memoryPayload);
|
|
318
|
+
const fileNames = Object.keys(changedFiles).join(', ');
|
|
319
|
+
log?.info?.(`[Whimmy] Sent memory sync for ${request.agentId}: ${fileNames}`);
|
|
320
|
+
}
|
|
321
|
+
} catch (memErr: any) {
|
|
322
|
+
log?.debug?.(`[Whimmy] Memory sync failed (non-fatal): ${memErr.message}`);
|
|
323
|
+
}
|
|
324
|
+
|
|
281
325
|
// Clear typing indicator and send chat.done.
|
|
282
326
|
const presenceIdle: ChatPresencePayload = {
|
|
283
327
|
sessionKey: request.sessionKey,
|
|
@@ -286,11 +330,45 @@ async function handleHookAgent(
|
|
|
286
330
|
};
|
|
287
331
|
sendEvent(ws, 'chat.presence', presenceIdle);
|
|
288
332
|
|
|
333
|
+
// Read token usage and context info from session store.
|
|
334
|
+
let tokenCount: number | undefined;
|
|
335
|
+
let cost: number | undefined;
|
|
336
|
+
let context: ChatChunkPayload['context'];
|
|
337
|
+
try {
|
|
338
|
+
const raw = readFileSync(storePath, 'utf-8');
|
|
339
|
+
const store = JSON.parse(raw) as Record<string, {
|
|
340
|
+
inputTokens?: number;
|
|
341
|
+
outputTokens?: number;
|
|
342
|
+
totalTokens?: number;
|
|
343
|
+
contextTokens?: number;
|
|
344
|
+
model?: string;
|
|
345
|
+
}>;
|
|
346
|
+
const entry = store[sessionKey] ?? store[mainSessionKey];
|
|
347
|
+
if (entry) {
|
|
348
|
+
const total = entry.totalTokens ?? ((entry.inputTokens ?? 0) + (entry.outputTokens ?? 0));
|
|
349
|
+
tokenCount = total > 0 ? total : undefined;
|
|
350
|
+
|
|
351
|
+
if (entry.contextTokens && entry.contextTokens > 0) {
|
|
352
|
+
const maxContext = resolveMaxContextTokens(entry.model ?? request.agentConfig.model);
|
|
353
|
+
context = {
|
|
354
|
+
used: entry.contextTokens,
|
|
355
|
+
max: maxContext,
|
|
356
|
+
percent: Math.round((entry.contextTokens / maxContext) * 100),
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
} catch {
|
|
361
|
+
// Session store may not exist yet on first message — ignore.
|
|
362
|
+
}
|
|
363
|
+
|
|
289
364
|
const done: ChatChunkPayload = {
|
|
290
365
|
sessionKey: request.sessionKey,
|
|
291
366
|
agentId: request.agentId,
|
|
292
367
|
content: '',
|
|
293
368
|
done: true,
|
|
369
|
+
tokenCount,
|
|
370
|
+
cost,
|
|
371
|
+
context,
|
|
294
372
|
};
|
|
295
373
|
sendEvent(ws, 'chat.done', done);
|
|
296
374
|
}
|
|
@@ -301,19 +379,13 @@ async function handleHookApproval(
|
|
|
301
379
|
): Promise<void> {
|
|
302
380
|
log?.info?.(`[Whimmy] Approval: execution=${request.executionId} approved=${request.approved}`);
|
|
303
381
|
|
|
304
|
-
const
|
|
305
|
-
if (
|
|
306
|
-
|
|
307
|
-
|
|
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}`);
|
|
382
|
+
const waiter = approvalWaiters.get(request.executionId);
|
|
383
|
+
if (waiter) {
|
|
384
|
+
approvalWaiters.delete(request.executionId);
|
|
385
|
+
waiter(request.approved);
|
|
386
|
+
log?.info?.(`[Whimmy] Resolved approval waiter ${request.executionId} → ${request.approved}`);
|
|
315
387
|
} else {
|
|
316
|
-
log?.warn?.(`[Whimmy]
|
|
388
|
+
log?.warn?.(`[Whimmy] No waiter for approval ${request.executionId} (expired or unknown)`);
|
|
317
389
|
}
|
|
318
390
|
}
|
|
319
391
|
|
|
@@ -348,6 +420,95 @@ export function broadcastApprovalRequest(payload: ExecApprovalRequestedPayload):
|
|
|
348
420
|
broadcastEvent('exec.approval.requested', payload);
|
|
349
421
|
}
|
|
350
422
|
|
|
423
|
+
/**
|
|
424
|
+
* Request approval from the user for a tool call.
|
|
425
|
+
* Sends the request to the app and waits for a response.
|
|
426
|
+
*/
|
|
427
|
+
export function requestApproval(
|
|
428
|
+
sessionKey: string,
|
|
429
|
+
agentId: string,
|
|
430
|
+
toolName: string,
|
|
431
|
+
params: Record<string, unknown>,
|
|
432
|
+
timeoutMs = 120_000,
|
|
433
|
+
): Promise<boolean> {
|
|
434
|
+
const executionId = randomUUID();
|
|
435
|
+
|
|
436
|
+
return new Promise<boolean>((resolve, reject) => {
|
|
437
|
+
const timer = setTimeout(() => {
|
|
438
|
+
approvalWaiters.delete(executionId);
|
|
439
|
+
reject(new Error(`Approval timed out after ${timeoutMs}ms (executionId=${executionId})`));
|
|
440
|
+
}, timeoutMs);
|
|
441
|
+
|
|
442
|
+
approvalWaiters.set(executionId, (approved) => {
|
|
443
|
+
clearTimeout(timer);
|
|
444
|
+
resolve(approved);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
broadcastApprovalRequest({
|
|
448
|
+
sessionKey,
|
|
449
|
+
agentId,
|
|
450
|
+
executionId,
|
|
451
|
+
toolName,
|
|
452
|
+
action: `${toolName}(${JSON.stringify(params).slice(0, 200)})`,
|
|
453
|
+
params,
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Forward an ask_user_question event to all connected Whimmy backends. */
|
|
459
|
+
export function broadcastAskUserQuestion(payload: AskUserQuestionPayload): void {
|
|
460
|
+
broadcastEvent('ask_user_question', payload);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Send a question to the user and wait for their answer.
|
|
465
|
+
* Returns the answers map keyed by question text → selected label(s).
|
|
466
|
+
*/
|
|
467
|
+
export function askUserQuestion(
|
|
468
|
+
sessionKey: string,
|
|
469
|
+
agentId: string,
|
|
470
|
+
questions: AskUserQuestion[],
|
|
471
|
+
timeoutMs = 120_000,
|
|
472
|
+
): Promise<Record<string, string>> {
|
|
473
|
+
const questionId = randomUUID();
|
|
474
|
+
|
|
475
|
+
return new Promise<Record<string, string>>((resolve, reject) => {
|
|
476
|
+
const timer = setTimeout(() => {
|
|
477
|
+
askUserQuestionWaiters.delete(questionId);
|
|
478
|
+
reject(new Error(`AskUserQuestion timed out after ${timeoutMs}ms (questionId=${questionId})`));
|
|
479
|
+
}, timeoutMs);
|
|
480
|
+
|
|
481
|
+
askUserQuestionWaiters.set(questionId, (answers) => {
|
|
482
|
+
clearTimeout(timer);
|
|
483
|
+
resolve(answers);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
const payload: AskUserQuestionPayload = {
|
|
487
|
+
sessionKey,
|
|
488
|
+
agentId,
|
|
489
|
+
questionId,
|
|
490
|
+
questions,
|
|
491
|
+
};
|
|
492
|
+
broadcastAskUserQuestion(payload);
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** Handle inbound hook.ask_user_answer from the backend. */
|
|
497
|
+
function handleHookAskUserAnswer(
|
|
498
|
+
request: HookAskUserAnswerRequest,
|
|
499
|
+
log?: Logger,
|
|
500
|
+
): void {
|
|
501
|
+
log?.info?.(`[Whimmy] AskUserAnswer: questionId=${request.questionId}`);
|
|
502
|
+
|
|
503
|
+
const waiter = askUserQuestionWaiters.get(request.questionId);
|
|
504
|
+
if (waiter) {
|
|
505
|
+
waiter(request.answers);
|
|
506
|
+
askUserQuestionWaiters.delete(request.questionId);
|
|
507
|
+
} else {
|
|
508
|
+
log?.warn?.(`[Whimmy] No waiter for ask_user_answer questionId=${request.questionId} (expired or unknown)`);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
351
512
|
// ============ Actions ============
|
|
352
513
|
|
|
353
514
|
function createWhimmyActions(ws: WebSocket, sessionKey: string, agentId: string) {
|
|
@@ -461,6 +622,11 @@ async function connectWebSocket(
|
|
|
461
622
|
handleHookRead(request, log);
|
|
462
623
|
break;
|
|
463
624
|
}
|
|
625
|
+
case 'hook.ask_user_answer': {
|
|
626
|
+
const request = env.payload as HookAskUserAnswerRequest;
|
|
627
|
+
handleHookAskUserAnswer(request, log);
|
|
628
|
+
break;
|
|
629
|
+
}
|
|
464
630
|
case 'tool.result': {
|
|
465
631
|
const result = env.payload as ToolResultPayload;
|
|
466
632
|
const waiter = toolResultWaiters.get(result.callId);
|
|
@@ -825,6 +991,25 @@ export const whimmyPlugin: WhimmyChannelPlugin = {
|
|
|
825
991
|
},
|
|
826
992
|
};
|
|
827
993
|
|
|
994
|
+
// ============ Tool Name Aliases ============
|
|
995
|
+
|
|
996
|
+
/**
|
|
997
|
+
* Map framework-internal tool names to user-facing names used in approval config.
|
|
998
|
+
* The backend sends tools like ["Bash","Write","Edit"], but the framework
|
|
999
|
+
* fires before_tool_call with internal names like "exec".
|
|
1000
|
+
*/
|
|
1001
|
+
const TOOL_APPROVAL_ALIASES: Record<string, string> = {
|
|
1002
|
+
exec: 'Bash',
|
|
1003
|
+
};
|
|
1004
|
+
|
|
1005
|
+
function toolMatchesApprovalList(toolName: string, toolList: string[]): boolean {
|
|
1006
|
+
if (toolList.includes('*')) return true;
|
|
1007
|
+
if (toolList.includes(toolName)) return true;
|
|
1008
|
+
const alias = TOOL_APPROVAL_ALIASES[toolName];
|
|
1009
|
+
if (alias && toolList.includes(alias)) return true;
|
|
1010
|
+
return false;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
828
1013
|
// ============ Tool Lifecycle Hooks ============
|
|
829
1014
|
|
|
830
1015
|
/**
|
|
@@ -832,12 +1017,124 @@ export const whimmyPlugin: WhimmyChannelPlugin = {
|
|
|
832
1017
|
* Called from index.ts during plugin registration.
|
|
833
1018
|
*/
|
|
834
1019
|
export function registerWhimmyHooks(api: OpenClawPluginApi): void {
|
|
835
|
-
api.on('before_tool_call', (event, ctx) => {
|
|
1020
|
+
api.on('before_tool_call', async (event, ctx) => {
|
|
836
1021
|
if (!ctx.sessionKey) return;
|
|
1022
|
+
|
|
1023
|
+
// Intercept AskUserQuestion: forward to Whimmy UI and wait for answer.
|
|
1024
|
+
if (event.toolName === 'AskUserQuestion' || event.toolName === 'ask_user_question') {
|
|
1025
|
+
const agentCfg = agentConfigCache.get(ctx.agentId || 'default');
|
|
1026
|
+
const auqConfig = agentCfg?.askUserQuestion;
|
|
1027
|
+
|
|
1028
|
+
// Skip if explicitly disabled.
|
|
1029
|
+
if (auqConfig?.enabled === false) return;
|
|
1030
|
+
|
|
1031
|
+
const questions = (event.params?.questions ?? []) as AskUserQuestion[];
|
|
1032
|
+
if (questions.length === 0) return;
|
|
1033
|
+
|
|
1034
|
+
// Extract sessionKey — strip the "agent:{agentId}:direct:" prefix to get
|
|
1035
|
+
// the original Whimmy session key.
|
|
1036
|
+
const parts = ctx.sessionKey.split(':');
|
|
1037
|
+
const whimmySessionKey = parts.length >= 4 ? parts.slice(3).join(':') : ctx.sessionKey;
|
|
1038
|
+
|
|
1039
|
+
const timeoutMs = auqConfig?.timeoutMs ?? 120_000;
|
|
1040
|
+
|
|
1041
|
+
try {
|
|
1042
|
+
const answers = await askUserQuestion(
|
|
1043
|
+
whimmySessionKey,
|
|
1044
|
+
ctx.agentId || 'default',
|
|
1045
|
+
questions,
|
|
1046
|
+
timeoutMs,
|
|
1047
|
+
);
|
|
1048
|
+
|
|
1049
|
+
// Return modified params with the user's answers filled in.
|
|
1050
|
+
return {
|
|
1051
|
+
params: {
|
|
1052
|
+
...event.params,
|
|
1053
|
+
answers,
|
|
1054
|
+
},
|
|
1055
|
+
};
|
|
1056
|
+
} catch (err: any) {
|
|
1057
|
+
api.logger?.warn?.(`[Whimmy] AskUserQuestion failed: ${err.message}`);
|
|
1058
|
+
return {
|
|
1059
|
+
block: true,
|
|
1060
|
+
blockReason: `User did not respond: ${err.message}`,
|
|
1061
|
+
};
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// Approval interception: check if this tool requires user approval.
|
|
1066
|
+
const agentId = ctx.agentId || 'default';
|
|
1067
|
+
const agentCfg = agentConfigCache.get(agentId);
|
|
1068
|
+
const approvalCfg = agentCfg?.approvals;
|
|
1069
|
+
|
|
1070
|
+
if (approvalCfg?.enabled) {
|
|
1071
|
+
const toolList = approvalCfg.tools ?? ['*'];
|
|
1072
|
+
const needsApproval = toolMatchesApprovalList(event.toolName, toolList);
|
|
1073
|
+
|
|
1074
|
+
if (needsApproval) {
|
|
1075
|
+
// Session mode: skip if already approved for this tool in this session.
|
|
1076
|
+
if (approvalCfg.mode === 'session') {
|
|
1077
|
+
const approved = sessionApprovals.get(agentId);
|
|
1078
|
+
if (approved?.has(event.toolName)) {
|
|
1079
|
+
// Already approved this session — fall through to lifecycle broadcast.
|
|
1080
|
+
} else {
|
|
1081
|
+
// Need to ask.
|
|
1082
|
+
const parts = ctx.sessionKey.split(':');
|
|
1083
|
+
const whimmySessionKey = parts.length >= 4 ? parts.slice(3).join(':') : ctx.sessionKey;
|
|
1084
|
+
const timeoutMs = approvalCfg.timeoutMs ?? 120_000;
|
|
1085
|
+
|
|
1086
|
+
try {
|
|
1087
|
+
const allowed = await requestApproval(
|
|
1088
|
+
whimmySessionKey,
|
|
1089
|
+
agentId,
|
|
1090
|
+
event.toolName,
|
|
1091
|
+
(event.params ?? {}) as Record<string, unknown>,
|
|
1092
|
+
timeoutMs,
|
|
1093
|
+
);
|
|
1094
|
+
|
|
1095
|
+
if (allowed) {
|
|
1096
|
+
// Remember for session mode.
|
|
1097
|
+
if (!sessionApprovals.has(agentId)) sessionApprovals.set(agentId, new Set());
|
|
1098
|
+
sessionApprovals.get(agentId)!.add(event.toolName);
|
|
1099
|
+
} else {
|
|
1100
|
+
return { block: true, blockReason: `User denied ${event.toolName}` };
|
|
1101
|
+
}
|
|
1102
|
+
} catch (err: any) {
|
|
1103
|
+
api.logger?.warn?.(`[Whimmy] Approval request failed: ${err.message}`);
|
|
1104
|
+
return { block: true, blockReason: `Approval timed out for ${event.toolName}` };
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
} else {
|
|
1108
|
+
// 'always' mode: ask every time.
|
|
1109
|
+
const parts = ctx.sessionKey.split(':');
|
|
1110
|
+
const whimmySessionKey = parts.length >= 4 ? parts.slice(3).join(':') : ctx.sessionKey;
|
|
1111
|
+
const timeoutMs = approvalCfg.timeoutMs ?? 120_000;
|
|
1112
|
+
|
|
1113
|
+
try {
|
|
1114
|
+
const allowed = await requestApproval(
|
|
1115
|
+
whimmySessionKey,
|
|
1116
|
+
agentId,
|
|
1117
|
+
event.toolName,
|
|
1118
|
+
(event.params ?? {}) as Record<string, unknown>,
|
|
1119
|
+
timeoutMs,
|
|
1120
|
+
);
|
|
1121
|
+
|
|
1122
|
+
if (!allowed) {
|
|
1123
|
+
return { block: true, blockReason: `User denied ${event.toolName}` };
|
|
1124
|
+
}
|
|
1125
|
+
} catch (err: any) {
|
|
1126
|
+
api.logger?.warn?.(`[Whimmy] Approval request failed: ${err.message}`);
|
|
1127
|
+
return { block: true, blockReason: `Approval timed out for ${event.toolName}` };
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// Default: broadcast tool.start lifecycle event.
|
|
837
1134
|
const executionId = randomUUID();
|
|
838
1135
|
const payload: ToolLifecyclePayload = {
|
|
839
1136
|
sessionKey: ctx.sessionKey,
|
|
840
|
-
agentId:
|
|
1137
|
+
agentId: agentId,
|
|
841
1138
|
executionId,
|
|
842
1139
|
toolName: event.toolName,
|
|
843
1140
|
status: 'running',
|
|
@@ -847,6 +1144,9 @@ export function registerWhimmyHooks(api: OpenClawPluginApi): void {
|
|
|
847
1144
|
|
|
848
1145
|
api.on('after_tool_call', (event, ctx) => {
|
|
849
1146
|
if (!ctx.sessionKey) return;
|
|
1147
|
+
// Skip lifecycle events for AskUserQuestion — already handled.
|
|
1148
|
+
if (event.toolName === 'AskUserQuestion' || event.toolName === 'ask_user_question') return;
|
|
1149
|
+
|
|
850
1150
|
const eventName = event.error ? 'tool.error' : 'tool.done';
|
|
851
1151
|
const payload: ToolLifecyclePayload = {
|
|
852
1152
|
sessionKey: ctx.sessionKey,
|
package/src/sync.ts
CHANGED
|
@@ -1,26 +1,30 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import type { OpenClawConfig } from 'openclaw/plugin-sdk';
|
|
6
6
|
import { getWhimmyRuntime } from './runtime';
|
|
7
|
-
import type { AgentConfig, Logger } from './types';
|
|
7
|
+
import type { AgentConfig, Logger, MemoryFileEntry } 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,
|
|
18
|
+
approvals: agentConfig.approvals ?? null,
|
|
19
|
+
askUserQuestion: agentConfig.askUserQuestion ?? null,
|
|
16
20
|
});
|
|
17
21
|
return createHash('sha256').update(data).digest('hex');
|
|
18
22
|
}
|
|
19
23
|
|
|
20
24
|
/**
|
|
21
25
|
* Ensure the Whimmy agent exists in OpenClaw's config with the correct
|
|
22
|
-
* model
|
|
23
|
-
* disk writes when nothing changed.
|
|
26
|
+
* model, system prompt, and skills. Uses an in-memory hash cache to skip
|
|
27
|
+
* redundant disk writes when nothing changed.
|
|
24
28
|
*/
|
|
25
29
|
export async function ensureWhimmyAgent(
|
|
26
30
|
agentId: string,
|
|
@@ -58,6 +62,48 @@ export async function ensureWhimmyAgent(
|
|
|
58
62
|
// Set model.
|
|
59
63
|
entry!.model = agentConfig.model;
|
|
60
64
|
|
|
65
|
+
// Set per-agent skill allowlist.
|
|
66
|
+
if (agentConfig.skills !== undefined) {
|
|
67
|
+
entry!.skills = agentConfig.skills;
|
|
68
|
+
log?.info?.(`[Whimmy] Agent ${agentId} skills: [${agentConfig.skills.join(', ')}]`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Sync global skill entries (enable/disable, API keys, env vars).
|
|
72
|
+
if (agentConfig.skillEntries && Object.keys(agentConfig.skillEntries).length > 0) {
|
|
73
|
+
if (!cfg.skills) {
|
|
74
|
+
(cfg as any).skills = {};
|
|
75
|
+
}
|
|
76
|
+
if (!cfg.skills!.entries) {
|
|
77
|
+
cfg.skills!.entries = {};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
for (const [skillName, skillConfig] of Object.entries(agentConfig.skillEntries)) {
|
|
81
|
+
cfg.skills!.entries![skillName] = {
|
|
82
|
+
...cfg.skills!.entries![skillName],
|
|
83
|
+
...skillConfig,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const names = Object.keys(agentConfig.skillEntries);
|
|
88
|
+
log?.info?.(`[Whimmy] Synced skill entries: [${names.join(', ')}]`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// When Whimmy handles approvals, disable framework-level exec prompting
|
|
92
|
+
// so the two systems don't race. See: ExecApprovalManager / ask modes.
|
|
93
|
+
if (agentConfig.approvals?.enabled) {
|
|
94
|
+
const tools = agentConfig.approvals.tools ?? ['*'];
|
|
95
|
+
log?.info?.(`[Whimmy] Approvals enabled: mode=${agentConfig.approvals.mode ?? 'always'} tools=[${tools.join(', ')}]`);
|
|
96
|
+
|
|
97
|
+
(entry as any).tools = {
|
|
98
|
+
...((entry as any).tools ?? {}),
|
|
99
|
+
exec: {
|
|
100
|
+
...(((entry as any).tools ?? {}).exec ?? {}),
|
|
101
|
+
ask: 'off',
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
log?.info?.(`[Whimmy] Set exec ask=off for ${agentId} (Whimmy is sole approval surface)`);
|
|
105
|
+
}
|
|
106
|
+
|
|
61
107
|
// Resolve workspace dir and write SOUL.md.
|
|
62
108
|
const workspace = cfg.agents?.defaults?.workspace
|
|
63
109
|
?? join(homedir(), '.openclaw', 'workspace');
|
|
@@ -84,3 +130,84 @@ export async function ensureWhimmyAgent(
|
|
|
84
130
|
|
|
85
131
|
return cfg;
|
|
86
132
|
}
|
|
133
|
+
|
|
134
|
+
// ============ Memory File Sync ============
|
|
135
|
+
|
|
136
|
+
/** In-memory hash cache for memory files: "agentId:filename" → SHA256 hash. */
|
|
137
|
+
const memoryHashCache = new Map<string, string>();
|
|
138
|
+
|
|
139
|
+
/** Top-level memory files to scan (excludes framework files like SOUL.md, BOOTSTRAP.md, AGENTS.md). */
|
|
140
|
+
const MEMORY_FILES = ['USER.md', 'IDENTITY.md', 'TOOLS.md', 'HEARTBEAT.md'];
|
|
141
|
+
|
|
142
|
+
/** Max number of files from memory/ subdir. */
|
|
143
|
+
const MEMORY_SUBDIR_MAX_FILES = 10;
|
|
144
|
+
|
|
145
|
+
/** Max total size in bytes for all memory files. */
|
|
146
|
+
const MEMORY_MAX_TOTAL_BYTES = 100 * 1024; // 100KB
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Collect memory files that have changed since the last sync.
|
|
150
|
+
* Returns a record of changed files, or null if nothing changed.
|
|
151
|
+
*/
|
|
152
|
+
export function collectChangedMemoryFiles(
|
|
153
|
+
agentId: string,
|
|
154
|
+
agentDir: string,
|
|
155
|
+
log?: Logger,
|
|
156
|
+
): Record<string, MemoryFileEntry> | null {
|
|
157
|
+
const changed: Record<string, MemoryFileEntry> = {};
|
|
158
|
+
let totalBytes = 0;
|
|
159
|
+
|
|
160
|
+
function tryFile(filename: string, filePath: string): void {
|
|
161
|
+
if (!existsSync(filePath)) return;
|
|
162
|
+
|
|
163
|
+
let content: string;
|
|
164
|
+
try {
|
|
165
|
+
content = readFileSync(filePath, 'utf-8');
|
|
166
|
+
} catch {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (!content.trim()) return;
|
|
171
|
+
|
|
172
|
+
const bytes = Buffer.byteLength(content, 'utf-8');
|
|
173
|
+
if (totalBytes + bytes > MEMORY_MAX_TOTAL_BYTES) {
|
|
174
|
+
log?.debug?.(`[Whimmy] Memory sync: skipping ${filename} (would exceed 100KB cap)`);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const hash = createHash('sha256').update(content).digest('hex');
|
|
179
|
+
const cacheKey = `${agentId}:${filename}`;
|
|
180
|
+
|
|
181
|
+
if (memoryHashCache.get(cacheKey) === hash) return;
|
|
182
|
+
|
|
183
|
+
totalBytes += bytes;
|
|
184
|
+
changed[filename] = { content, hash };
|
|
185
|
+
memoryHashCache.set(cacheKey, hash);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Scan top-level memory files.
|
|
189
|
+
for (const filename of MEMORY_FILES) {
|
|
190
|
+
tryFile(filename, join(agentDir, filename));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Scan memory/ subdir for *.md files.
|
|
194
|
+
const memoryDir = join(agentDir, 'memory');
|
|
195
|
+
if (existsSync(memoryDir)) {
|
|
196
|
+
try {
|
|
197
|
+
const entries = readdirSync(memoryDir)
|
|
198
|
+
.filter(f => f.endsWith('.md'))
|
|
199
|
+
.slice(0, MEMORY_SUBDIR_MAX_FILES);
|
|
200
|
+
|
|
201
|
+
for (const entry of entries) {
|
|
202
|
+
const filename = `memory/${entry}`;
|
|
203
|
+
tryFile(filename, join(memoryDir, entry));
|
|
204
|
+
}
|
|
205
|
+
} catch {
|
|
206
|
+
// memory/ dir may not be readable — ignore.
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (Object.keys(changed).length === 0) return null;
|
|
211
|
+
|
|
212
|
+
return changed;
|
|
213
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -50,6 +50,41 @@ 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
|
+
/** Approval settings — controls whether exec commands require user approval via Whimmy. */
|
|
58
|
+
approvals?: ApprovalConfig;
|
|
59
|
+
/** AskUserQuestion settings — controls interactive question forwarding to Whimmy. */
|
|
60
|
+
askUserQuestion?: AskUserQuestionConfig;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** SkillEntryConfig — per-skill configuration synced from Whimmy. */
|
|
64
|
+
export interface SkillEntryConfig {
|
|
65
|
+
enabled?: boolean;
|
|
66
|
+
apiKey?: string;
|
|
67
|
+
env?: Record<string, string>;
|
|
68
|
+
config?: Record<string, unknown>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** ApprovalConfig — controls tool approval forwarding to Whimmy. */
|
|
72
|
+
export interface ApprovalConfig {
|
|
73
|
+
/** Enable approval flow. Default: false. */
|
|
74
|
+
enabled?: boolean;
|
|
75
|
+
/** 'always' = every matched tool call needs approval, 'session' = approve once per session. */
|
|
76
|
+
mode?: 'session' | 'always';
|
|
77
|
+
/** Tool names that require approval. Omit or ['*'] = all tools. */
|
|
78
|
+
tools?: string[];
|
|
79
|
+
/** Timeout in ms before auto-denying. Default: 120000 (2 min). */
|
|
80
|
+
timeoutMs?: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** AskUserQuestionConfig — controls AskUserQuestion interception. */
|
|
84
|
+
export interface AskUserQuestionConfig {
|
|
85
|
+
enabled?: boolean;
|
|
86
|
+
/** Timeout in milliseconds before auto-blocking. Default: 120000 (2 min). */
|
|
87
|
+
timeoutMs?: number;
|
|
53
88
|
}
|
|
54
89
|
|
|
55
90
|
/** HookAttachment describes a file attached to a user message. */
|
|
@@ -129,6 +164,18 @@ export interface ChatChunkPayload {
|
|
|
129
164
|
messageId?: string;
|
|
130
165
|
tokenCount?: number;
|
|
131
166
|
cost?: number;
|
|
167
|
+
/** Context window usage stats (only on chat.done). */
|
|
168
|
+
context?: ContextUsage;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** ContextUsage — how full the agent's context window is. */
|
|
172
|
+
export interface ContextUsage {
|
|
173
|
+
/** Current context tokens used. */
|
|
174
|
+
used: number;
|
|
175
|
+
/** Max context tokens for the model. */
|
|
176
|
+
max: number;
|
|
177
|
+
/** Percentage of context used (0-100). */
|
|
178
|
+
percent: number;
|
|
132
179
|
}
|
|
133
180
|
|
|
134
181
|
/** ChatMediaPayload — file or voice message sent back to backend. */
|
|
@@ -193,6 +240,37 @@ export interface HookReadRequest {
|
|
|
193
240
|
messageId?: string;
|
|
194
241
|
}
|
|
195
242
|
|
|
243
|
+
// ============ AskUserQuestion Protocol ============
|
|
244
|
+
|
|
245
|
+
/** Option in a multiple-choice question. */
|
|
246
|
+
export interface AskUserQuestionOption {
|
|
247
|
+
label: string;
|
|
248
|
+
description: string;
|
|
249
|
+
markdown?: string;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** A single question with options. */
|
|
253
|
+
export interface AskUserQuestion {
|
|
254
|
+
question: string;
|
|
255
|
+
header: string;
|
|
256
|
+
options: AskUserQuestionOption[];
|
|
257
|
+
multiSelect: boolean;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** AskUserQuestionPayload — sent to backend when agent needs user input. */
|
|
261
|
+
export interface AskUserQuestionPayload {
|
|
262
|
+
sessionKey: string;
|
|
263
|
+
agentId: string;
|
|
264
|
+
questionId: string;
|
|
265
|
+
questions: AskUserQuestion[];
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** HookAskUserAnswerRequest — backend sends this with the user's answers. */
|
|
269
|
+
export interface HookAskUserAnswerRequest {
|
|
270
|
+
questionId: string;
|
|
271
|
+
answers: Record<string, string>;
|
|
272
|
+
}
|
|
273
|
+
|
|
196
274
|
/** ExecApprovalRequestedPayload — approval request sent back to backend. */
|
|
197
275
|
export interface ExecApprovalRequestedPayload {
|
|
198
276
|
sessionKey: string;
|
|
@@ -200,6 +278,21 @@ export interface ExecApprovalRequestedPayload {
|
|
|
200
278
|
executionId: string;
|
|
201
279
|
toolName: string;
|
|
202
280
|
action: string;
|
|
281
|
+
/** Tool call parameters — so the app can show what exactly is being requested. */
|
|
282
|
+
params?: Record<string, unknown>;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** MemoryFileEntry — a single memory file with content and hash. */
|
|
286
|
+
export interface MemoryFileEntry {
|
|
287
|
+
content: string;
|
|
288
|
+
hash: string;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** AgentMemorySyncPayload — syncs changed memory files to the backend. */
|
|
292
|
+
export interface AgentMemorySyncPayload {
|
|
293
|
+
sessionKey: string;
|
|
294
|
+
agentId: string;
|
|
295
|
+
files: Record<string, MemoryFileEntry>;
|
|
203
296
|
}
|
|
204
297
|
|
|
205
298
|
/** ToolLifecyclePayload — tool start/done/error sent back to backend. */
|
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);
|