@whimmy-ai/whimmy 0.5.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 +228 -33
- package/src/sync.ts +101 -2
- package/src/types.ts +50 -0
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,
|
|
@@ -33,28 +36,14 @@ import type {
|
|
|
33
36
|
ToolResultPayload,
|
|
34
37
|
HistoryMessage,
|
|
35
38
|
AgentInfo,
|
|
39
|
+
AgentConfig,
|
|
40
|
+
AgentMemorySyncPayload,
|
|
36
41
|
} from './types';
|
|
37
42
|
|
|
38
|
-
// ============
|
|
43
|
+
// ============ Per-Agent Config Cache ============
|
|
39
44
|
|
|
40
|
-
/**
|
|
41
|
-
|
|
42
|
-
* The full class isn't exported from openclaw/plugin-sdk's barrel,
|
|
43
|
-
* so we type just the method we need.
|
|
44
|
-
*/
|
|
45
|
-
interface ApprovalManagerLike {
|
|
46
|
-
resolve(recordId: string, decision: 'allow-once' | 'allow-always' | 'deny', resolvedBy?: string | null): boolean;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
let approvalManager: ApprovalManagerLike | null = null;
|
|
50
|
-
|
|
51
|
-
export function setApprovalManager(manager: ApprovalManagerLike): void {
|
|
52
|
-
approvalManager = manager;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export function getApprovalManager(): ApprovalManagerLike | null {
|
|
56
|
-
return approvalManager;
|
|
57
|
-
}
|
|
45
|
+
/** Stores the latest AgentConfig per agentId so hooks can read it. */
|
|
46
|
+
const agentConfigCache = new Map<string, AgentConfig>();
|
|
58
47
|
|
|
59
48
|
// ============ Config Helpers ============
|
|
60
49
|
|
|
@@ -93,11 +82,34 @@ function sendEvent(ws: WebSocket, event: string, payload: unknown): boolean {
|
|
|
93
82
|
/** Pending tool call results: callId → resolve function */
|
|
94
83
|
const toolResultWaiters = new Map<string, (result: ToolResultPayload) => void>();
|
|
95
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
|
+
|
|
96
93
|
// ============ AskUserQuestion Waiters ============
|
|
97
94
|
|
|
98
95
|
/** Pending user question answers: questionId → resolve function */
|
|
99
96
|
const askUserQuestionWaiters = new Map<string, (answers: Record<string, string>) => void>();
|
|
100
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
|
+
|
|
101
113
|
// ============ History Formatting ============
|
|
102
114
|
|
|
103
115
|
function formatHistoryForAgent(history: HistoryMessage[]): string {
|
|
@@ -135,6 +147,9 @@ async function handleHookAgent(
|
|
|
135
147
|
|
|
136
148
|
log?.info?.(`[Whimmy] Inbound: agent=${request.agentId} session=${request.sessionKey} text="${request.message.slice(0, 80)}..."`);
|
|
137
149
|
|
|
150
|
+
// Cache agent config so hooks can read it later.
|
|
151
|
+
agentConfigCache.set(request.agentId, request.agentConfig);
|
|
152
|
+
|
|
138
153
|
// Sync Whimmy agent config into OpenClaw (model + system prompt).
|
|
139
154
|
const syncedCfg = await ensureWhimmyAgent(request.agentId, request.agentConfig, log);
|
|
140
155
|
|
|
@@ -286,6 +301,27 @@ async function handleHookAgent(
|
|
|
286
301
|
log?.error?.(`[Whimmy] dispatch error: ${dispatchErr.message}`);
|
|
287
302
|
}
|
|
288
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
|
+
|
|
289
325
|
// Clear typing indicator and send chat.done.
|
|
290
326
|
const presenceIdle: ChatPresencePayload = {
|
|
291
327
|
sessionKey: request.sessionKey,
|
|
@@ -294,11 +330,45 @@ async function handleHookAgent(
|
|
|
294
330
|
};
|
|
295
331
|
sendEvent(ws, 'chat.presence', presenceIdle);
|
|
296
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
|
+
|
|
297
364
|
const done: ChatChunkPayload = {
|
|
298
365
|
sessionKey: request.sessionKey,
|
|
299
366
|
agentId: request.agentId,
|
|
300
367
|
content: '',
|
|
301
368
|
done: true,
|
|
369
|
+
tokenCount,
|
|
370
|
+
cost,
|
|
371
|
+
context,
|
|
302
372
|
};
|
|
303
373
|
sendEvent(ws, 'chat.done', done);
|
|
304
374
|
}
|
|
@@ -309,19 +379,13 @@ async function handleHookApproval(
|
|
|
309
379
|
): Promise<void> {
|
|
310
380
|
log?.info?.(`[Whimmy] Approval: execution=${request.executionId} approved=${request.approved}`);
|
|
311
381
|
|
|
312
|
-
const
|
|
313
|
-
if (
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
const decision = request.approved ? 'allow-once' as const : 'deny' as const;
|
|
319
|
-
const resolved = manager.resolve(request.executionId, decision, 'whimmy');
|
|
320
|
-
|
|
321
|
-
if (resolved) {
|
|
322
|
-
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}`);
|
|
323
387
|
} else {
|
|
324
|
-
log?.warn?.(`[Whimmy]
|
|
388
|
+
log?.warn?.(`[Whimmy] No waiter for approval ${request.executionId} (expired or unknown)`);
|
|
325
389
|
}
|
|
326
390
|
}
|
|
327
391
|
|
|
@@ -356,6 +420,41 @@ export function broadcastApprovalRequest(payload: ExecApprovalRequestedPayload):
|
|
|
356
420
|
broadcastEvent('exec.approval.requested', payload);
|
|
357
421
|
}
|
|
358
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
|
+
|
|
359
458
|
/** Forward an ask_user_question event to all connected Whimmy backends. */
|
|
360
459
|
export function broadcastAskUserQuestion(payload: AskUserQuestionPayload): void {
|
|
361
460
|
broadcastEvent('ask_user_question', payload);
|
|
@@ -892,6 +991,25 @@ export const whimmyPlugin: WhimmyChannelPlugin = {
|
|
|
892
991
|
},
|
|
893
992
|
};
|
|
894
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
|
+
|
|
895
1013
|
// ============ Tool Lifecycle Hooks ============
|
|
896
1014
|
|
|
897
1015
|
/**
|
|
@@ -904,6 +1022,12 @@ export function registerWhimmyHooks(api: OpenClawPluginApi): void {
|
|
|
904
1022
|
|
|
905
1023
|
// Intercept AskUserQuestion: forward to Whimmy UI and wait for answer.
|
|
906
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
|
+
|
|
907
1031
|
const questions = (event.params?.questions ?? []) as AskUserQuestion[];
|
|
908
1032
|
if (questions.length === 0) return;
|
|
909
1033
|
|
|
@@ -912,11 +1036,14 @@ export function registerWhimmyHooks(api: OpenClawPluginApi): void {
|
|
|
912
1036
|
const parts = ctx.sessionKey.split(':');
|
|
913
1037
|
const whimmySessionKey = parts.length >= 4 ? parts.slice(3).join(':') : ctx.sessionKey;
|
|
914
1038
|
|
|
1039
|
+
const timeoutMs = auqConfig?.timeoutMs ?? 120_000;
|
|
1040
|
+
|
|
915
1041
|
try {
|
|
916
1042
|
const answers = await askUserQuestion(
|
|
917
1043
|
whimmySessionKey,
|
|
918
1044
|
ctx.agentId || 'default',
|
|
919
1045
|
questions,
|
|
1046
|
+
timeoutMs,
|
|
920
1047
|
);
|
|
921
1048
|
|
|
922
1049
|
// Return modified params with the user's answers filled in.
|
|
@@ -935,11 +1062,79 @@ export function registerWhimmyHooks(api: OpenClawPluginApi): void {
|
|
|
935
1062
|
}
|
|
936
1063
|
}
|
|
937
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
|
+
|
|
938
1133
|
// Default: broadcast tool.start lifecycle event.
|
|
939
1134
|
const executionId = randomUUID();
|
|
940
1135
|
const payload: ToolLifecyclePayload = {
|
|
941
1136
|
sessionKey: ctx.sessionKey,
|
|
942
|
-
agentId:
|
|
1137
|
+
agentId: agentId,
|
|
943
1138
|
executionId,
|
|
944
1139
|
toolName: event.toolName,
|
|
945
1140
|
status: 'running',
|
package/src/sync.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
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
9
|
/** In-memory cache: agentId → hash of full synced config. */
|
|
10
10
|
const hashCache = new Map<string, string>();
|
|
@@ -15,6 +15,8 @@ function computeHash(agentConfig: AgentConfig): string {
|
|
|
15
15
|
systemPrompt: agentConfig.systemPrompt ?? '',
|
|
16
16
|
skills: agentConfig.skills ?? null,
|
|
17
17
|
skillEntries: agentConfig.skillEntries ?? null,
|
|
18
|
+
approvals: agentConfig.approvals ?? null,
|
|
19
|
+
askUserQuestion: agentConfig.askUserQuestion ?? null,
|
|
18
20
|
});
|
|
19
21
|
return createHash('sha256').update(data).digest('hex');
|
|
20
22
|
}
|
|
@@ -86,6 +88,22 @@ export async function ensureWhimmyAgent(
|
|
|
86
88
|
log?.info?.(`[Whimmy] Synced skill entries: [${names.join(', ')}]`);
|
|
87
89
|
}
|
|
88
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
|
+
|
|
89
107
|
// Resolve workspace dir and write SOUL.md.
|
|
90
108
|
const workspace = cfg.agents?.defaults?.workspace
|
|
91
109
|
?? join(homedir(), '.openclaw', 'workspace');
|
|
@@ -112,3 +130,84 @@ export async function ensureWhimmyAgent(
|
|
|
112
130
|
|
|
113
131
|
return cfg;
|
|
114
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
|
@@ -54,6 +54,10 @@ export interface AgentConfig {
|
|
|
54
54
|
skills?: string[];
|
|
55
55
|
/** Global skill entries to sync (enable/disable, API keys, env vars). */
|
|
56
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;
|
|
57
61
|
}
|
|
58
62
|
|
|
59
63
|
/** SkillEntryConfig — per-skill configuration synced from Whimmy. */
|
|
@@ -64,6 +68,25 @@ export interface SkillEntryConfig {
|
|
|
64
68
|
config?: Record<string, unknown>;
|
|
65
69
|
}
|
|
66
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;
|
|
88
|
+
}
|
|
89
|
+
|
|
67
90
|
/** HookAttachment describes a file attached to a user message. */
|
|
68
91
|
export interface HookAttachment {
|
|
69
92
|
filePath: string;
|
|
@@ -141,6 +164,18 @@ export interface ChatChunkPayload {
|
|
|
141
164
|
messageId?: string;
|
|
142
165
|
tokenCount?: number;
|
|
143
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;
|
|
144
179
|
}
|
|
145
180
|
|
|
146
181
|
/** ChatMediaPayload — file or voice message sent back to backend. */
|
|
@@ -243,6 +278,21 @@ export interface ExecApprovalRequestedPayload {
|
|
|
243
278
|
executionId: string;
|
|
244
279
|
toolName: string;
|
|
245
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>;
|
|
246
296
|
}
|
|
247
297
|
|
|
248
298
|
/** ToolLifecyclePayload — tool start/done/error sent back to backend. */
|