@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 CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { OpenClawPluginApi } from 'openclaw/plugin-sdk';
2
- import { whimmyPlugin, registerWhimmyHooks, setApprovalManager, broadcastApprovalRequest } from './src/channel';
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@whimmy-ai/whimmy",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Whimmy channel plugin for OpenClaw",
5
5
  "main": "index.ts",
6
6
  "type": "module",
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
- // ============ Exec Approval Manager Singleton ============
43
+ // ============ Per-Agent Config Cache ============
36
44
 
37
- /**
38
- * Minimal interface matching ExecApprovalManager.resolve().
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 manager = getApprovalManager();
305
- if (!manager) {
306
- log?.warn?.(`[Whimmy] ExecApprovalManager not yet captured — cannot resolve execution ${request.executionId}`);
307
- return;
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] Failed to resolve execution ${request.executionId} (expired or unknown)`);
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: ctx.agentId || 'default',
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 { 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,
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 and system prompt. Uses an in-memory hash cache to skip redundant
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/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);