@joshualelon/clawdbot-skill-flow 2.3.4 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joshualelon/clawdbot-skill-flow",
3
- "version": "2.3.4",
3
+ "version": "2.4.0",
4
4
  "type": "module",
5
5
  "description": "Multi-step workflow orchestration plugin for Clawdbot",
6
6
  "keywords": [
@@ -13,11 +13,6 @@ import {
13
13
  } from "../state/session-store.js";
14
14
  import { saveFlowHistory } from "../state/history-store.js";
15
15
  import { processStep } from "../engine/executor.js";
16
- import {
17
- loadHooks,
18
- resolveFlowPath,
19
- safeExecuteHook,
20
- } from "../engine/hooks-loader.js";
21
16
 
22
17
  export function createFlowStepCommand(api: ClawdbotPluginApi) {
23
18
  return async (args: {
@@ -68,29 +63,6 @@ export function createFlowStepCommand(api: ClawdbotPluginApi) {
68
63
  const session = getSession(sessionKey);
69
64
 
70
65
  if (!session) {
71
- // Load hooks and call onFlowAbandoned
72
- if (flow.hooks) {
73
- const hooksPath = resolveFlowPath(api, flow.name, flow.hooks);
74
- const hooks = await loadHooks(api, hooksPath);
75
- if (hooks?.lifecycle?.onFlowAbandoned) {
76
- await safeExecuteHook(
77
- api,
78
- "onFlowAbandoned",
79
- hooks.lifecycle.onFlowAbandoned,
80
- {
81
- flowName,
82
- currentStepId: stepId,
83
- senderId: args.senderId,
84
- channel: args.channel,
85
- variables: {},
86
- startedAt: 0,
87
- lastActivityAt: 0,
88
- },
89
- "timeout"
90
- );
91
- }
92
- }
93
-
94
66
  return {
95
67
  text: `Session expired or not found.\n\nUse /flow_start ${flowName} to restart the flow.`,
96
68
  };
@@ -8,272 +8,148 @@ import type {
8
8
  FlowSession,
9
9
  FlowStep,
10
10
  ReplyPayload,
11
- LoadedHooks,
12
- ConditionalAction,
13
- FetchAction,
14
- BeforeRenderAction,
15
- EnhancedPluginApi,
16
11
  DeclarativeAction,
17
12
  } from "../types.js";
18
13
  import { renderStep } from "./renderer.js";
19
14
  import { executeTransition } from "./transitions.js";
20
- import { loadHooks, resolveFlowPath, safeExecuteHook, safeExecuteAction, validateFlowActions } from "./hooks-loader.js";
21
- import * as pluginHooks from "../hooks/index.js";
22
15
  import { loadActionRegistry, type ActionRegistry } from "./action-loader.js";
23
16
  import { evaluateCondition } from "./condition-evaluator.js";
24
17
  import { executeDeclarativeAction } from "./action-executor.js";
25
18
  import { createInterpolationContext, interpolateConfig } from "./interpolation.js";
26
19
 
27
- /**
28
- * Determine if an action should execute based on conditional logic
29
- */
30
- export function shouldExecuteAction(
31
- action: ConditionalAction,
32
- session: FlowSession
33
- ): { execute: boolean; actionName: string } {
34
- // Check if condition is specified
35
- if (action.if) {
36
- const conditionValue = session.variables[action.if];
37
- const execute = Boolean(conditionValue);
38
- return { execute, actionName: action.action };
39
- }
40
-
41
- // No condition, always execute
42
- return { execute: true, actionName: action.action };
43
- }
44
-
45
20
  /**
46
21
  * Execute step-level actions (fetch, beforeRender)
47
- * Supports both declarative actions (new) and hooks (legacy)
48
22
  */
49
23
  async function executeStepActions(
50
24
  api: ClawdbotPluginApi,
51
25
  step: FlowStep,
52
26
  session: FlowSession,
53
27
  flow: FlowMetadata,
54
- hooks: LoadedHooks | null,
55
- actionRegistry: ActionRegistry | null
28
+ actionRegistry: ActionRegistry
56
29
  ): Promise<{ step: FlowStep; session: FlowSession }> {
30
+ api.logger.info(`[ACTIONS] executeStepActions called for step: ${step.id}`);
31
+
57
32
  if (!step.actions) {
33
+ api.logger.debug(`[ACTIONS] No actions defined for step ${step.id}`);
58
34
  return { step, session };
59
35
  }
60
36
 
37
+ api.logger.info(`[ACTIONS] Step ${step.id} has actions. Fetch: ${step.actions.fetch ? Object.keys(step.actions.fetch).length : 0}, BeforeRender: ${step.actions.beforeRender?.length || 0}, AfterCapture: ${step.actions.afterCapture?.length || 0}`);
38
+
61
39
  let modifiedStep = step;
62
40
  let modifiedSession = { ...session };
41
+ const context = createInterpolationContext(modifiedSession, flow.env || {});
63
42
 
64
- // Check if actions are declarative (new) or legacy hooks
65
- const firstAction =
66
- step.actions.fetch && Object.values(step.actions.fetch)[0] ||
67
- step.actions.beforeRender?.[0];
68
-
69
- const isDeclarative = firstAction && "type" in firstAction;
70
-
71
- if (isDeclarative && actionRegistry) {
72
- // NEW: Declarative action system
73
- const context = createInterpolationContext(modifiedSession, flow.env || {});
43
+ // 1. Execute fetch actions
44
+ if (step.actions.fetch) {
45
+ api.logger.info(`[FETCH] Executing ${Object.keys(step.actions.fetch).length} fetch action(s) for step ${step.id}`);
74
46
 
75
- // 1. Execute fetch actions
76
- if (step.actions.fetch) {
77
- for (const [varName, action] of Object.entries(step.actions.fetch)) {
78
- const declarativeAction = action as DeclarativeAction;
47
+ for (const [varName, action] of Object.entries(step.actions.fetch)) {
48
+ const declarativeAction = action as DeclarativeAction;
49
+ api.logger.info(`[FETCH] Starting fetch action: ${declarativeAction.type} -> ${varName}`);
79
50
 
80
- // Evaluate condition
81
- if (declarativeAction.if && !evaluateCondition(declarativeAction.if, modifiedSession)) {
82
- api.logger.debug(`Skipping fetch action ${declarativeAction.type} - condition not met`);
83
- continue;
84
- }
51
+ // Evaluate condition
52
+ if (declarativeAction.if && !evaluateCondition(declarativeAction.if, modifiedSession)) {
53
+ api.logger.debug(`[FETCH] Skipping fetch action ${declarativeAction.type} - condition not met`);
54
+ continue;
55
+ }
85
56
 
86
- // Interpolate config
87
- const config = interpolateConfig(declarativeAction.config, context);
88
-
89
- try {
90
- // Execute action
91
- const result = await executeDeclarativeAction(
92
- declarativeAction.type,
93
- config,
94
- { session: modifiedSession, api, step: modifiedStep },
95
- actionRegistry
96
- );
97
-
98
- // Inject result into session
99
- if (result !== null && result !== undefined) {
100
- if (typeof result === "object") {
101
- // If result is an object, inject all its fields as separate variables
102
- const resultObj = result as Record<string, unknown>;
103
- for (const [key, value] of Object.entries(resultObj)) {
104
- if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
105
- modifiedSession = {
106
- ...modifiedSession,
107
- variables: {
108
- ...modifiedSession.variables,
109
- [key]: value,
110
- },
111
- };
112
- }
57
+ // Interpolate config
58
+ const config = interpolateConfig(declarativeAction.config, context);
59
+ api.logger.debug(`[FETCH] Interpolated config: ${JSON.stringify(config)}`);
60
+
61
+ try {
62
+ // Execute action
63
+ api.logger.info(`[FETCH] Executing ${declarativeAction.type}...`);
64
+ const result = await executeDeclarativeAction(
65
+ declarativeAction.type,
66
+ config,
67
+ { session: modifiedSession, api, step: modifiedStep },
68
+ actionRegistry
69
+ );
70
+ api.logger.info(`[FETCH] Action ${declarativeAction.type} completed. Result type: ${typeof result}`);
71
+
72
+ // Inject result into session
73
+ if (result !== null && result !== undefined) {
74
+ if (typeof result === "object") {
75
+ // If result is an object, inject all its fields as separate variables
76
+ const resultObj = result as Record<string, unknown>;
77
+ api.logger.info(`[FETCH] Injecting ${Object.keys(resultObj).length} variables from result: ${Object.keys(resultObj).join(', ')}`);
78
+
79
+ for (const [key, value] of Object.entries(resultObj)) {
80
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
81
+ modifiedSession = {
82
+ ...modifiedSession,
83
+ variables: {
84
+ ...modifiedSession.variables,
85
+ [key]: value,
86
+ },
87
+ };
88
+ api.logger.debug(`[FETCH] Injected ${key} = ${value}`);
113
89
  }
114
- // Update context for next actions
115
- context.variables = modifiedSession.variables;
116
- } else if (typeof result === "string" || typeof result === "number" || typeof result === "boolean") {
117
- // If result is a primitive, inject it under varName
118
- modifiedSession = {
119
- ...modifiedSession,
120
- variables: {
121
- ...modifiedSession.variables,
122
- [varName]: result,
123
- },
124
- };
125
- // Update context for next actions
126
- context.variables = modifiedSession.variables;
127
90
  }
91
+ // Update context for next actions
92
+ context.variables = modifiedSession.variables;
93
+ } else if (typeof result === "string" || typeof result === "number" || typeof result === "boolean") {
94
+ // If result is a primitive, inject it under varName
95
+ api.logger.info(`[FETCH] Injecting primitive result as ${varName} = ${result}`);
96
+ modifiedSession = {
97
+ ...modifiedSession,
98
+ variables: {
99
+ ...modifiedSession.variables,
100
+ [varName]: result,
101
+ },
102
+ };
103
+ // Update context for next actions
104
+ context.variables = modifiedSession.variables;
128
105
  }
129
- } catch (error) {
130
- const errorMsg = error instanceof Error ? error.message : String(error);
131
- api.logger.error(`Fetch action ${declarativeAction.type} failed: ${errorMsg}`);
132
- // Continue with other actions
106
+ } else {
107
+ api.logger.warn(`[FETCH] Action ${declarativeAction.type} returned null/undefined`);
133
108
  }
134
- }
135
- }
136
-
137
- // 2. Execute beforeRender actions
138
- if (step.actions.beforeRender) {
139
- for (const action of step.actions.beforeRender) {
140
- const declarativeAction = action as DeclarativeAction;
141
-
142
- // Evaluate condition
143
- if (declarativeAction.if && !evaluateCondition(declarativeAction.if, modifiedSession)) {
144
- api.logger.debug(`Skipping beforeRender action ${declarativeAction.type} - condition not met`);
145
- continue;
146
- }
147
-
148
- // Interpolate config
149
- const config = interpolateConfig(declarativeAction.config, context);
150
-
151
- try {
152
- // Execute action
153
- const result = await executeDeclarativeAction(
154
- declarativeAction.type,
155
- config,
156
- { session: modifiedSession, api, step: modifiedStep },
157
- actionRegistry
158
- );
159
-
160
- if (result && typeof result === "object") {
161
- modifiedStep = result as FlowStep;
162
- }
163
- } catch (error) {
164
- const errorMsg = error instanceof Error ? error.message : String(error);
165
- api.logger.error(`BeforeRender action ${declarativeAction.type} failed: ${errorMsg}`);
166
- // Continue with other actions
109
+ } catch (error) {
110
+ const errorMsg = error instanceof Error ? error.message : String(error);
111
+ const errorStack = error instanceof Error ? error.stack : '';
112
+ api.logger.error(`[FETCH] Fetch action ${declarativeAction.type} failed: ${errorMsg}`);
113
+ if (errorStack) {
114
+ api.logger.debug(`[FETCH] Stack trace: ${errorStack}`);
167
115
  }
116
+ // Continue with other actions
168
117
  }
169
118
  }
170
- } else if (hooks) {
171
- // LEGACY: Hook-based action system
172
- const enhancedApi: EnhancedPluginApi = {
173
- ...api,
174
- hooks: pluginHooks,
175
- };
176
119
 
177
- // 1. Execute fetch actions - inject variables into session
178
- if (step.actions.fetch) {
179
- for (const [varName, action] of Object.entries(step.actions.fetch)) {
180
- // Check if it's a legacy action (has "action" field instead of "type")
181
- if (!("action" in action)) continue;
182
-
183
- const legacyAction = action as unknown as ConditionalAction;
184
- // Check if action should execute
185
- const { execute, actionName} = shouldExecuteAction(legacyAction, modifiedSession);
186
-
187
- if (!execute) {
188
- api.logger.debug(
189
- `Skipping fetch action "${actionName}" for variable "${varName}" - condition not met`
190
- );
191
- continue;
192
- }
120
+ api.logger.info(`[FETCH] Fetch actions complete. Variables in session: ${Object.keys(modifiedSession.variables).join(', ')}`);
121
+ }
193
122
 
194
- const fetchFn = hooks.actions[actionName] as FetchAction | undefined;
195
- if (fetchFn) {
196
- // Validate signature before calling
197
- if (fetchFn.length > 2) {
198
- api.logger.error(
199
- `Fetch action "${actionName}" has invalid signature. Expected 1-2 parameters (session, api?), got ${fetchFn.length}`
200
- );
201
- continue;
202
- }
123
+ // 2. Execute beforeRender actions
124
+ if (step.actions.beforeRender) {
125
+ for (const action of step.actions.beforeRender) {
126
+ const declarativeAction = action as DeclarativeAction;
203
127
 
204
- const result = await safeExecuteAction(
205
- api,
206
- actionName,
207
- fetchFn,
208
- modifiedSession,
209
- enhancedApi
210
- );
211
- if (result !== null && result !== undefined) {
212
- if (typeof result === "object") {
213
- // If result is an object, inject all its fields as separate variables
214
- for (const [key, value] of Object.entries(result)) {
215
- if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
216
- modifiedSession = {
217
- ...modifiedSession,
218
- variables: {
219
- ...modifiedSession.variables,
220
- [key]: value,
221
- },
222
- };
223
- }
224
- }
225
- } else if (typeof result === "string" || typeof result === "number" || typeof result === "boolean") {
226
- // If result is a primitive, inject it under varName
227
- modifiedSession = {
228
- ...modifiedSession,
229
- variables: {
230
- ...modifiedSession.variables,
231
- [varName]: result,
232
- },
233
- };
234
- }
235
- }
236
- }
128
+ // Evaluate condition
129
+ if (declarativeAction.if && !evaluateCondition(declarativeAction.if, modifiedSession)) {
130
+ api.logger.debug(`Skipping beforeRender action ${declarativeAction.type} - condition not met`);
131
+ continue;
237
132
  }
238
- }
239
-
240
- // 2. Execute beforeRender actions - modify step
241
- if (step.actions.beforeRender) {
242
- for (const action of step.actions.beforeRender) {
243
- // Check if it's a legacy action (has "action" field instead of "type")
244
- if (!("action" in action)) continue;
245
133
 
246
- const legacyAction = action as unknown as ConditionalAction;
247
- const { execute, actionName } = shouldExecuteAction(legacyAction, modifiedSession);
134
+ // Interpolate config
135
+ const config = interpolateConfig(declarativeAction.config, context);
248
136
 
249
- if (!execute) {
250
- api.logger.debug(
251
- `Skipping beforeRender action "${actionName}" - condition not met`
252
- );
253
- continue;
254
- }
255
-
256
- const beforeRenderFn = hooks.actions[actionName] as BeforeRenderAction | undefined;
257
- if (beforeRenderFn) {
258
- if (beforeRenderFn.length > 3) {
259
- api.logger.error(
260
- `BeforeRender action "${actionName}" has invalid signature. Expected 2-3 parameters (step, session, api?), got ${beforeRenderFn.length}`
261
- );
262
- continue;
263
- }
137
+ try {
138
+ // Execute action
139
+ const result = await executeDeclarativeAction(
140
+ declarativeAction.type,
141
+ config,
142
+ { session: modifiedSession, api, step: modifiedStep },
143
+ actionRegistry
144
+ );
264
145
 
265
- const result = await safeExecuteAction(
266
- api,
267
- actionName,
268
- beforeRenderFn,
269
- modifiedStep,
270
- modifiedSession,
271
- enhancedApi
272
- );
273
- if (result) {
274
- modifiedStep = result as FlowStep;
275
- }
146
+ if (result && typeof result === "object") {
147
+ modifiedStep = result as FlowStep;
276
148
  }
149
+ } catch (error) {
150
+ const errorMsg = error instanceof Error ? error.message : String(error);
151
+ api.logger.error(`BeforeRender action ${declarativeAction.type} failed: ${errorMsg}`);
152
+ // Continue with other actions
277
153
  }
278
154
  }
279
155
  }
@@ -297,29 +173,9 @@ export async function startFlow(
297
173
  };
298
174
  }
299
175
 
300
- // Load action registry for declarative actions
301
- let actionRegistry: ActionRegistry | null = null;
302
- if (flow.actions?.imports || !flow.hooks) {
303
- try {
304
- actionRegistry = await loadActionRegistry(flow.actions?.imports);
305
- api.logger.debug(`Loaded action registry with ${actionRegistry.list().length} actions`);
306
- } catch (error) {
307
- const errorMsg = error instanceof Error ? error.message : String(error);
308
- api.logger.error(`Failed to load action registry: ${errorMsg}`);
309
- }
310
- }
311
-
312
- // Load hooks if configured (legacy support)
313
- let hooks: LoadedHooks | null = null;
314
- if (flow.hooks) {
315
- const hooksPath = resolveFlowPath(api, flow.name, flow.hooks);
316
- hooks = await loadHooks(api, hooksPath);
317
-
318
- // Validate that all action references in the flow exist
319
- if (hooks) {
320
- validateFlowActions(flow, hooks, api);
321
- }
322
- }
176
+ // Load action registry
177
+ const actionRegistry = await loadActionRegistry(flow.actions?.imports);
178
+ api.logger.debug(`Loaded action registry with ${actionRegistry.list().length} actions`);
323
179
 
324
180
  // Inject environment variables into session
325
181
  if (flow.env) {
@@ -347,11 +203,11 @@ export async function startFlow(
347
203
  }
348
204
 
349
205
  // Execute step actions before rendering
350
- const actionResult = await executeStepActions(api, firstStep, session, flow, hooks, actionRegistry);
206
+ const actionResult = await executeStepActions(api, firstStep, session, flow, actionRegistry);
351
207
  firstStep = actionResult.step;
352
208
  session = actionResult.session;
353
209
 
354
- return renderStep(api, flow, firstStep, session, session.channel, hooks);
210
+ return renderStep(api, flow, firstStep, session, session.channel);
355
211
  }
356
212
 
357
213
  /**
@@ -368,28 +224,8 @@ export async function processStep(
368
224
  complete: boolean;
369
225
  updatedVariables: Record<string, string | number | boolean>;
370
226
  }> {
371
- // Load action registry for declarative actions
372
- let actionRegistry: ActionRegistry | null = null;
373
- if (flow.actions?.imports || !flow.hooks) {
374
- try {
375
- actionRegistry = await loadActionRegistry(flow.actions?.imports);
376
- } catch (error) {
377
- const errorMsg = error instanceof Error ? error.message : String(error);
378
- api.logger.error(`Failed to load action registry: ${errorMsg}`);
379
- }
380
- }
381
-
382
- // Load hooks if configured (legacy support)
383
- let hooks: LoadedHooks | null = null;
384
- if (flow.hooks) {
385
- const hooksPath = resolveFlowPath(api, flow.name, flow.hooks);
386
- hooks = await loadHooks(api, hooksPath);
387
-
388
- // Validate that all action references in the flow exist
389
- if (hooks) {
390
- validateFlowActions(flow, hooks, api);
391
- }
392
- }
227
+ // Load action registry
228
+ const actionRegistry = await loadActionRegistry(flow.actions?.imports);
393
229
 
394
230
  const result = await executeTransition(
395
231
  api,
@@ -397,7 +233,6 @@ export async function processStep(
397
233
  session,
398
234
  stepId,
399
235
  value,
400
- hooks,
401
236
  actionRegistry
402
237
  );
403
238
 
@@ -412,13 +247,6 @@ export async function processStep(
412
247
 
413
248
  // Handle completion
414
249
  if (result.complete) {
415
- const updatedSession = { ...session, variables: result.variables };
416
-
417
- // Call onFlowComplete hook
418
- if (hooks?.lifecycle?.onFlowComplete) {
419
- await safeExecuteHook(api, "onFlowComplete", hooks.lifecycle.onFlowComplete, updatedSession);
420
- }
421
-
422
250
  const completionMessage = generateCompletionMessage(flow, result.variables);
423
251
  return {
424
252
  reply: { text: completionMessage },
@@ -448,7 +276,7 @@ export async function processStep(
448
276
  let updatedSession = { ...session, variables: result.variables };
449
277
 
450
278
  // Execute step actions before rendering
451
- const actionResult = await executeStepActions(api, nextStep, updatedSession, flow, hooks, actionRegistry);
279
+ const actionResult = await executeStepActions(api, nextStep, updatedSession, flow, actionRegistry);
452
280
  nextStep = actionResult.step;
453
281
  updatedSession = actionResult.session;
454
282
 
@@ -457,8 +285,7 @@ export async function processStep(
457
285
  flow,
458
286
  nextStep,
459
287
  updatedSession,
460
- session.channel,
461
- hooks
288
+ session.channel
462
289
  );
463
290
 
464
291
  return {
@@ -8,7 +8,6 @@ import type {
8
8
  FlowStep,
9
9
  FlowSession,
10
10
  ReplyPayload,
11
- LoadedHooks,
12
11
  } from "../types.js";
13
12
  import { normalizeButton } from "../validation.js";
14
13
 
@@ -107,18 +106,14 @@ function renderFallback(
107
106
  /**
108
107
  * Render a flow step for the user's channel
109
108
  *
110
- * Note: Step actions (fetch, beforeRender) are now executed in executor.ts
111
- * before this function is called. The api and hooks parameters are retained for:
112
- * - API consistency with other engine functions
113
- * - Future extensibility (e.g., channel-specific rendering hooks)
114
- * - Backwards compatibility if deprecated hooks are re-enabled
109
+ * Note: Step actions (fetch, beforeRender) are executed in executor.ts
110
+ * before this function is called.
115
111
  *
116
112
  * @param _api - Plugin API (reserved for future use)
117
113
  * @param flow - Flow metadata
118
114
  * @param step - Step to render (already modified by beforeRender actions)
119
115
  * @param session - Current session with variables (already populated by fetch actions)
120
116
  * @param channel - Target channel (telegram, slack, etc.)
121
- * @param _hooks - Loaded hooks (reserved for future use)
122
117
  * @returns Rendered message payload for the channel
123
118
  */
124
119
  export async function renderStep(
@@ -126,8 +121,7 @@ export async function renderStep(
126
121
  flow: FlowMetadata,
127
122
  step: FlowStep,
128
123
  session: FlowSession,
129
- channel: string,
130
- _hooks?: LoadedHooks | null
124
+ channel: string
131
125
  ): Promise<ReplyPayload> {
132
126
  // Channel-specific rendering
133
127
  if (channel === "telegram") {
@@ -8,18 +8,11 @@ import type {
8
8
  FlowStep,
9
9
  FlowSession,
10
10
  TransitionResult,
11
- LoadedHooks,
12
- AfterCaptureAction,
13
- EnhancedPluginApi,
14
11
  DeclarativeAction,
15
- ConditionalAction,
16
12
  } from "../types.js";
17
13
  import { normalizeButton, validateInput } from "../validation.js";
18
- import { safeExecuteAction } from "./hooks-loader.js";
19
14
  import { sanitizeInput } from "../security/input-sanitization.js";
20
15
  import { getPluginConfig } from "../config.js";
21
- import { shouldExecuteAction } from "./executor.js";
22
- import * as pluginHooks from "../hooks/index.js";
23
16
  import type { ActionRegistry } from "./action-loader.js";
24
17
  import { evaluateCondition as evaluateDeclarativeCondition } from "./condition-evaluator.js";
25
18
  import { executeDeclarativeAction } from "./action-executor.js";
@@ -106,15 +99,8 @@ export async function executeTransition(
106
99
  session: FlowSession,
107
100
  stepId: string,
108
101
  value: string | number,
109
- hooks?: LoadedHooks | null,
110
- actionRegistry?: ActionRegistry | null
102
+ actionRegistry: ActionRegistry
111
103
  ): Promise<TransitionResult> {
112
- // Create enhanced API with plugin utilities
113
- const enhancedApi: EnhancedPluginApi = {
114
- ...api,
115
- hooks: pluginHooks,
116
- };
117
-
118
104
  // Find current step
119
105
  const step = flow.steps.find((s) => s.id === stepId);
120
106
 
@@ -171,82 +157,38 @@ export async function executeTransition(
171
157
  // Execute afterCapture actions
172
158
  if (step.actions?.afterCapture) {
173
159
  const updatedSession = { ...session, variables: updatedVariables };
160
+ const context = createInterpolationContext(updatedSession, flow.env || {});
174
161
 
175
- // Check if actions are declarative (new) or legacy hooks
176
- const firstAction = step.actions.afterCapture[0];
177
- const isDeclarative = firstAction && "type" in firstAction;
178
-
179
- if (isDeclarative && actionRegistry) {
180
- // NEW: Declarative action system
181
- const context = createInterpolationContext(updatedSession, flow.env || {});
182
-
183
- for (const action of step.actions.afterCapture) {
184
- const declarativeAction = action as DeclarativeAction;
162
+ for (const action of step.actions.afterCapture) {
163
+ const declarativeAction = action as DeclarativeAction;
185
164
 
186
- // Evaluate condition
187
- if (declarativeAction.if && !evaluateDeclarativeCondition(declarativeAction.if, updatedSession)) {
188
- api.logger.debug(`Skipping afterCapture action ${declarativeAction.type} - condition not met`);
189
- continue;
190
- }
191
-
192
- // Interpolate config
193
- const config = interpolateConfig(declarativeAction.config, context);
194
-
195
- try {
196
- // Execute action
197
- await executeDeclarativeAction(
198
- declarativeAction.type,
199
- config,
200
- {
201
- session: updatedSession,
202
- api,
203
- step,
204
- capturedVariable: step.capture,
205
- capturedValue: capturedValue,
206
- },
207
- actionRegistry
208
- );
209
- } catch (error) {
210
- const errorMsg = error instanceof Error ? error.message : String(error);
211
- api.logger.error(`AfterCapture action ${declarativeAction.type} failed: ${errorMsg}`);
212
- // Continue with other actions
213
- }
165
+ // Evaluate condition
166
+ if (declarativeAction.if && !evaluateDeclarativeCondition(declarativeAction.if, updatedSession)) {
167
+ api.logger.debug(`Skipping afterCapture action ${declarativeAction.type} - condition not met`);
168
+ continue;
214
169
  }
215
- } else if (hooks) {
216
- // LEGACY: Hook-based action system
217
- for (const action of step.actions.afterCapture) {
218
- // Check if it's a legacy action (has "action" field instead of "type")
219
- if (!("action" in action)) continue;
220
-
221
- const legacyAction = action as unknown as ConditionalAction;
222
- const { execute, actionName } = shouldExecuteAction(legacyAction, updatedSession);
223
-
224
- if (!execute) {
225
- api.logger.debug(
226
- `Skipping afterCapture action "${actionName}" - condition not met`
227
- );
228
- continue;
229
- }
230
170
 
231
- const afterCaptureFn = hooks.actions[actionName] as AfterCaptureAction | undefined;
232
- if (afterCaptureFn) {
233
- if (afterCaptureFn.length > 4) {
234
- api.logger.error(
235
- `AfterCapture action "${actionName}" has invalid signature. Expected 3-4 parameters (variable, value, session, api?), got ${afterCaptureFn.length}`
236
- );
237
- continue;
238
- }
171
+ // Interpolate config
172
+ const config = interpolateConfig(declarativeAction.config, context);
239
173
 
240
- await safeExecuteAction(
174
+ try {
175
+ // Execute action
176
+ await executeDeclarativeAction(
177
+ declarativeAction.type,
178
+ config,
179
+ {
180
+ session: updatedSession,
241
181
  api,
242
- actionName,
243
- afterCaptureFn,
244
- step.capture,
245
- capturedValue,
246
- updatedSession,
247
- enhancedApi
248
- );
249
- }
182
+ step,
183
+ capturedVariable: step.capture,
184
+ capturedValue: capturedValue,
185
+ },
186
+ actionRegistry
187
+ );
188
+ } catch (error) {
189
+ const errorMsg = error instanceof Error ? error.message : String(error);
190
+ api.logger.error(`AfterCapture action ${declarativeAction.type} failed: ${errorMsg}`);
191
+ // Continue with other actions
250
192
  }
251
193
  }
252
194
  }
@@ -5,13 +5,10 @@
5
5
  import { promises as fs } from "node:fs";
6
6
  import path from "node:path";
7
7
  import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
8
- import type { FlowSession, FlowMetadata } from "../types.js";
8
+ import type { FlowSession, FlowMetadata, StorageBackend } from "../types.js";
9
9
  import type { SkillFlowConfig } from "../config.js";
10
10
  import { getPluginConfig } from "../config.js";
11
- import {
12
- loadStorageBackend,
13
- resolveFlowPath,
14
- } from "../engine/hooks-loader.js";
11
+ import { resolvePathSafely, validatePathWithinBase } from "../security/path-validation.js";
15
12
 
16
13
  /**
17
14
  * Get the flows directory path (same logic as flow-store.ts)
@@ -26,6 +23,43 @@ function getFlowsDir(api: ClawdbotPluginApi): string {
26
23
  return path.join(stateDir, "flows");
27
24
  }
28
25
 
26
+ /**
27
+ * Resolve relative path to absolute (relative to flow directory)
28
+ */
29
+ function resolveFlowPath(
30
+ api: ClawdbotPluginApi,
31
+ flowName: string,
32
+ relativePath: string
33
+ ): string {
34
+ const flowsDir = getFlowsDir(api);
35
+ const flowDir = path.join(flowsDir, flowName);
36
+ return resolvePathSafely(flowDir, relativePath, "flow path");
37
+ }
38
+
39
+ /**
40
+ * Load custom storage backend module
41
+ */
42
+ async function loadStorageBackend(
43
+ api: ClawdbotPluginApi,
44
+ backendPath: string
45
+ ): Promise<StorageBackend | null> {
46
+ try {
47
+ const flowsDir = getFlowsDir(api);
48
+ validatePathWithinBase(backendPath, flowsDir, "storage backend");
49
+
50
+ await fs.access(backendPath);
51
+
52
+ const backendModule = await import(backendPath);
53
+ const backend: StorageBackend = backendModule.default ?? backendModule;
54
+
55
+ api.logger.debug(`Loaded storage backend from ${backendPath}`);
56
+ return backend;
57
+ } catch (error) {
58
+ api.logger.warn(`Failed to load storage backend from ${backendPath}:`, error);
59
+ return null;
60
+ }
61
+ }
62
+
29
63
  /**
30
64
  * Get history file path for a flow
31
65
  */
@@ -1,305 +0,0 @@
1
- /**
2
- * Hooks loader - Dynamically loads and executes flow hooks
3
- */
4
-
5
- import type { FlowHooks, StorageBackend, LoadedHooks } from "../types.js";
6
- import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
7
- import { promises as fs } from "node:fs";
8
- import path from "node:path";
9
- import {
10
- resolvePathSafely,
11
- validatePathWithinBase,
12
- } from "../security/path-validation.js";
13
- import { getPluginConfig } from "../config.js";
14
- import { withTimeout, TimeoutError } from "../security/timeout.js";
15
- import * as pluginHooks from "../hooks/index.js";
16
-
17
- /**
18
- * Get the flows directory path (same logic as flow-store.ts)
19
- */
20
- function getFlowsDir(api: ClawdbotPluginApi): string {
21
- const config = getPluginConfig();
22
- if (config.flowsDir) {
23
- const expandedPath = config.flowsDir.replace(/^~/, process.env.HOME || "~");
24
- return path.resolve(expandedPath);
25
- }
26
- const stateDir = api.runtime.state.resolveStateDir();
27
- return path.join(stateDir, "flows");
28
- }
29
-
30
- /**
31
- * Load hooks from a file path
32
- * @param api - Plugin API for logging
33
- * @param hooksPath - Absolute path to hooks file
34
- * @returns LoadedHooks object with lifecycle hooks and actions, or null if loading fails
35
- */
36
- export async function loadHooks(
37
- api: ClawdbotPluginApi,
38
- hooksPath: string
39
- ): Promise<LoadedHooks | null> {
40
- try {
41
- // Validate path is within flows directory
42
- const flowsDir = getFlowsDir(api);
43
- validatePathWithinBase(hooksPath, flowsDir, "hooks file");
44
-
45
- // Check if file exists
46
- await fs.access(hooksPath);
47
-
48
- // Dynamically import the hooks module
49
- const hooksModule = await import(hooksPath);
50
-
51
- // Extract default export for lifecycle hooks
52
- let moduleExport = hooksModule.default ?? {};
53
-
54
- // Support factory functions that take API and return lifecycle hooks
55
- // Inject plugin hooks utilities into the API for easy access
56
- if (typeof moduleExport === "function") {
57
- const enhancedApi = {
58
- ...api,
59
- hooks: pluginHooks, // Make plugin utilities available via api.hooks
60
- };
61
- moduleExport = moduleExport(enhancedApi);
62
- }
63
-
64
- // Extract global lifecycle hooks from default export
65
- const lifecycle: FlowHooks = {
66
- onFlowComplete: moduleExport?.onFlowComplete,
67
- onFlowAbandoned: moduleExport?.onFlowAbandoned,
68
- };
69
-
70
- // Extract all named exports as step-level actions
71
- const actions: Record<string, Function> = {};
72
- for (const [key, value] of Object.entries(hooksModule)) {
73
- if (key !== "default" && typeof value === "function") {
74
- actions[key] = value;
75
- }
76
- }
77
-
78
- api.logger.debug(`Loaded hooks from ${hooksPath}: ${Object.keys(actions).length} actions, ${Object.keys(lifecycle).filter(k => lifecycle[k as keyof FlowHooks]).length} lifecycle hooks`);
79
- return { lifecycle, actions };
80
- } catch (error) {
81
- api.logger.warn(`Failed to load hooks from ${hooksPath}:`, error);
82
- return null;
83
- }
84
- }
85
-
86
- /**
87
- * Load storage backend from a file path
88
- * @param api - Plugin API for logging
89
- * @param backendPath - Absolute path to storage backend file
90
- * @returns StorageBackend or null if loading fails
91
- */
92
- export async function loadStorageBackend(
93
- api: ClawdbotPluginApi,
94
- backendPath: string
95
- ): Promise<StorageBackend | null> {
96
- try {
97
- // Validate path is within flows directory
98
- const flowsDir = getFlowsDir(api);
99
- validatePathWithinBase(backendPath, flowsDir, "storage backend");
100
-
101
- // Check if file exists
102
- await fs.access(backendPath);
103
-
104
- // Dynamically import the backend module
105
- const backendModule = await import(backendPath);
106
-
107
- // Support both default export and named export
108
- const backend: StorageBackend = backendModule.default ?? backendModule;
109
-
110
- api.logger.debug(`Loaded storage backend from ${backendPath}`);
111
- return backend;
112
- } catch (error) {
113
- api.logger.warn(`Failed to load storage backend from ${backendPath}:`, error);
114
- return null;
115
- }
116
- }
117
-
118
- /**
119
- * Resolve relative path to absolute (relative to flow directory)
120
- * @param api - Plugin API
121
- * @param flowName - Name of the flow
122
- * @param relativePath - Relative path from flow config
123
- * @returns Absolute path
124
- */
125
- export function resolveFlowPath(
126
- api: ClawdbotPluginApi,
127
- flowName: string,
128
- relativePath: string
129
- ): string {
130
- const flowsDir = getFlowsDir(api);
131
- const flowDir = path.join(flowsDir, flowName);
132
-
133
- // Validate that relativePath doesn't escape flowDir
134
- return resolvePathSafely(flowDir, relativePath, "flow path");
135
- }
136
-
137
- /**
138
- * Safe hook executor - wraps hook calls with error handling
139
- * @param api - Plugin API for logging
140
- * @param hookName - Name of the hook being called
141
- * @param hookFn - The hook function to execute
142
- * @param args - Arguments to pass to the hook
143
- * @returns Result of hook or undefined if hook fails
144
- */
145
- export async function safeExecuteHook<T, Args extends unknown[]>(
146
- api: ClawdbotPluginApi,
147
- hookName: string,
148
- hookFn: ((...args: Args) => T | Promise<T>) | undefined,
149
- ...args: Args
150
- ): Promise<T | undefined> {
151
- if (!hookFn) {
152
- return undefined;
153
- }
154
-
155
- const config = getPluginConfig();
156
- const timeout = config.security.hookTimeout;
157
-
158
- try {
159
- const result = await withTimeout(
160
- () => Promise.resolve(hookFn(...args)),
161
- timeout,
162
- `Hook "${hookName}" timed out after ${timeout}ms`
163
- );
164
- return result;
165
- } catch (error) {
166
- const isTimeout = error instanceof TimeoutError;
167
- const errorMsg = isTimeout
168
- ? `Hook ${hookName} timed out after ${timeout}ms`
169
- : `Hook ${hookName} failed: ${error}`;
170
-
171
- api.logger.error(errorMsg);
172
- return undefined;
173
- }
174
- }
175
-
176
- /**
177
- * Safe action executor - wraps action calls with error handling
178
- * @param api - Plugin API for logging
179
- * @param actionName - Name of the action being called
180
- * @param actionFn - The action function to execute
181
- * @param args - Arguments to pass to the action
182
- * @returns Result of action or undefined if action fails
183
- */
184
- export async function safeExecuteAction<T, Args extends unknown[]>(
185
- api: ClawdbotPluginApi,
186
- actionName: string,
187
- actionFn: ((...args: Args) => T | Promise<T>) | undefined,
188
- ...args: Args
189
- ): Promise<T | undefined> {
190
- if (!actionFn) {
191
- api.logger.warn(`Action ${actionName} not found in hooks`);
192
- return undefined;
193
- }
194
-
195
- const config = getPluginConfig();
196
- const strategy = config.actions?.fetchFailureStrategy || "warn";
197
- const timeout = config.security.actionTimeout;
198
-
199
- try {
200
- const result = await withTimeout(
201
- () => Promise.resolve(actionFn(...args)),
202
- timeout,
203
- `Action "${actionName}" timed out after ${timeout}ms`
204
- );
205
- return result;
206
- } catch (error) {
207
- const isTimeout = error instanceof TimeoutError;
208
- const errorMsg = isTimeout
209
- ? `Action ${actionName} timed out after ${timeout}ms`
210
- : `Action ${actionName} failed: ${error}`;
211
-
212
- if (strategy === "stop") {
213
- api.logger.error(errorMsg);
214
- throw error; // Re-throw to stop flow execution
215
- } else if (strategy === "warn") {
216
- api.logger.warn(errorMsg);
217
- }
218
- // 'silent' strategy logs nothing
219
-
220
- return undefined;
221
- }
222
- }
223
-
224
- /**
225
- * Extract action name from ConditionalAction or DeclarativeAction
226
- */
227
- function getActionName(
228
- action: import("../types.js").ConditionalAction | import("../types.js").DeclarativeAction
229
- ): string {
230
- // Check if it's a legacy ConditionalAction with "action" field
231
- if ("action" in action) {
232
- return action.action;
233
- }
234
- // Otherwise it's a DeclarativeAction with "type" field
235
- if ("type" in action) {
236
- return action.type;
237
- }
238
- return "unknown";
239
- }
240
-
241
- /**
242
- * Validate that all actions referenced in flow steps exist in hooks
243
- * @param flow - Flow metadata with steps
244
- * @param hooks - Loaded hooks with actions
245
- * @param api - Plugin API for logging
246
- * @throws Error if any action references are invalid
247
- */
248
- export function validateFlowActions(
249
- flow: import("../types.js").FlowMetadata,
250
- hooks: LoadedHooks,
251
- api: ClawdbotPluginApi
252
- ): void {
253
- const availableActions = Object.keys(hooks.actions);
254
- const errors: string[] = [];
255
-
256
- for (const step of flow.steps) {
257
- if (!step.actions) continue;
258
-
259
- // Validate fetch actions
260
- if (step.actions.fetch) {
261
- for (const [varName, action] of Object.entries(step.actions.fetch)) {
262
- const actionName = getActionName(action);
263
- if (!availableActions.includes(actionName)) {
264
- errors.push(
265
- `Step "${step.id}": fetch action "${actionName}" not found (for variable "${varName}")`
266
- );
267
- }
268
- }
269
- }
270
-
271
- // Validate beforeRender actions
272
- if (step.actions.beforeRender) {
273
- for (const action of step.actions.beforeRender) {
274
- const actionName = getActionName(action);
275
- if (!availableActions.includes(actionName)) {
276
- errors.push(
277
- `Step "${step.id}": beforeRender action "${actionName}" not found`
278
- );
279
- }
280
- }
281
- }
282
-
283
- // Validate afterCapture actions
284
- if (step.actions.afterCapture) {
285
- for (const action of step.actions.afterCapture) {
286
- const actionName = getActionName(action);
287
- if (!availableActions.includes(actionName)) {
288
- errors.push(
289
- `Step "${step.id}": afterCapture action "${actionName}" not found`
290
- );
291
- }
292
- }
293
- }
294
- }
295
-
296
- if (errors.length > 0) {
297
- const errorMsg = `Flow "${flow.name}" has invalid action references:\n${errors.join('\n')}`;
298
- api.logger.error(errorMsg);
299
-
300
- throw new Error(
301
- `Flow "${flow.name}" references ${errors.length} action(s) that don't exist in hooks file. ` +
302
- `Available actions: ${availableActions.join(', ') || '(none)'}`
303
- );
304
- }
305
- }