@joshualelon/clawdbot-skill-flow 0.3.1 → 0.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.
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Common utilities for hooks library
3
+ */
4
+
5
+ import type { FlowHooks, FlowSession } from "../types.js";
6
+ import type { HookFunction, ConditionFunction, RetryOptions } from "./types.js";
7
+
8
+ /**
9
+ * Compose multiple hooks of the same type into a single hook.
10
+ * Hooks are executed in sequence. If any hook throws, execution stops.
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * const combined = composeHooks(
15
+ * createSheetsLogger({ ... }),
16
+ * async (variable, value, session) => {
17
+ * await sendSlackNotification(session);
18
+ * }
19
+ * );
20
+ * ```
21
+ */
22
+ export function composeHooks<T extends keyof FlowHooks>(
23
+ ...hooks: Array<HookFunction<T>>
24
+ ): HookFunction<T> {
25
+ return (async (...args: unknown[]) => {
26
+ let result: unknown;
27
+ for (const hook of hooks) {
28
+ // @ts-expect-error - complex typing, but runtime is safe
29
+ result = await hook(...args);
30
+ }
31
+ return result;
32
+ }) as HookFunction<T>;
33
+ }
34
+
35
+ /**
36
+ * Retry a function with exponential backoff.
37
+ * Useful for network requests or external API calls.
38
+ *
39
+ * @param fn - Function to retry
40
+ * @param options - Retry configuration
41
+ * @param options.maxAttempts - Maximum number of attempts (default: 3)
42
+ * @param options.delayMs - Initial delay in milliseconds (default: 1000)
43
+ * @param options.backoff - Use exponential backoff (default: true)
44
+ * @param options.maxDelayMs - Maximum delay cap (default: 30000 = 30 seconds)
45
+ *
46
+ * @example
47
+ * ```ts
48
+ * const data = await withRetry(
49
+ * () => fetch('https://api.example.com/data'),
50
+ * { maxAttempts: 3, delayMs: 1000, backoff: true }
51
+ * );
52
+ * ```
53
+ */
54
+ export async function withRetry<T>(
55
+ fn: () => Promise<T>,
56
+ options: RetryOptions = {}
57
+ ): Promise<T> {
58
+ const {
59
+ maxAttempts = 3,
60
+ delayMs = 1000,
61
+ backoff = true,
62
+ maxDelayMs = 30000,
63
+ } = options;
64
+
65
+ let lastError: Error | unknown;
66
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
67
+ try {
68
+ return await fn();
69
+ } catch (error) {
70
+ lastError = error;
71
+ if (attempt === maxAttempts) {
72
+ break;
73
+ }
74
+
75
+ // Calculate delay with optional exponential backoff and max cap
76
+ const exponentialDelay = delayMs * Math.pow(2, attempt - 1);
77
+ const delay = backoff
78
+ ? Math.min(exponentialDelay, maxDelayMs)
79
+ : delayMs;
80
+ await sleep(delay);
81
+ }
82
+ }
83
+
84
+ throw lastError;
85
+ }
86
+
87
+ /**
88
+ * Sleep for a given number of milliseconds
89
+ */
90
+ function sleep(ms: number): Promise<void> {
91
+ return new Promise((resolve) => setTimeout(resolve, ms));
92
+ }
93
+
94
+ /**
95
+ * Validate email address format
96
+ * Uses simple regex - not perfect but catches most invalid formats
97
+ */
98
+ export function validateEmail(value: string): boolean {
99
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
100
+ return emailRegex.test(value);
101
+ }
102
+
103
+ /**
104
+ * Validate number with optional min/max bounds
105
+ */
106
+ export function validateNumber(
107
+ value: unknown,
108
+ min?: number,
109
+ max?: number
110
+ ): boolean {
111
+ // Reject empty strings, null, undefined
112
+ if (value === "" || value === null || value === undefined) {
113
+ return false;
114
+ }
115
+
116
+ const num = typeof value === "number" ? value : Number(value);
117
+
118
+ if (Number.isNaN(num)) {
119
+ return false;
120
+ }
121
+
122
+ if (min !== undefined && num < min) {
123
+ return false;
124
+ }
125
+
126
+ if (max !== undefined && num > max) {
127
+ return false;
128
+ }
129
+
130
+ return true;
131
+ }
132
+
133
+ /**
134
+ * Validate phone number format (basic validation)
135
+ * Accepts various formats: +1234567890, (123) 456-7890, 123-456-7890
136
+ */
137
+ export function validatePhone(value: string): boolean {
138
+ // Remove all non-digit characters
139
+ const digits = value.replace(/\D/g, "");
140
+ // Check if we have 10-15 digits (covers most international formats)
141
+ return digits.length >= 10 && digits.length <= 15;
142
+ }
143
+
144
+ /**
145
+ * Conditional hook execution - only run hook if condition is met
146
+ *
147
+ * @example
148
+ * ```ts
149
+ * const logOnComplete = whenCondition(
150
+ * (session) => session.variables.score > 100,
151
+ * createSheetsLogger({ ... })
152
+ * );
153
+ * ```
154
+ */
155
+ export function whenCondition<T extends keyof FlowHooks>(
156
+ condition: ConditionFunction,
157
+ hook: HookFunction<T>
158
+ ): HookFunction<T> {
159
+ return (async (...args: unknown[]) => {
160
+ // Session is typically the last argument for most hooks
161
+ const session = args[args.length - 1] as FlowSession;
162
+
163
+ const shouldRun = await condition(session);
164
+ if (shouldRun) {
165
+ // @ts-expect-error - complex typing, but runtime is safe
166
+ return await hook(...args);
167
+ }
168
+ }) as HookFunction<T>;
169
+ }
170
+
171
+ /**
172
+ * Create a debounced version of a hook (useful for rate limiting)
173
+ * Ensures hook is only called once within a time window
174
+ */
175
+ export function debounceHook<T extends keyof FlowHooks>(
176
+ hook: HookFunction<T>,
177
+ delayMs: number
178
+ ): HookFunction<T> {
179
+ let timeoutId: NodeJS.Timeout | null = null;
180
+ let pendingArgs: unknown[] | null = null;
181
+
182
+ return (async (...args: unknown[]) => {
183
+ pendingArgs = args;
184
+
185
+ if (timeoutId) {
186
+ clearTimeout(timeoutId);
187
+ }
188
+
189
+ return new Promise((resolve) => {
190
+ timeoutId = setTimeout(async () => {
191
+ if (pendingArgs) {
192
+ // @ts-expect-error - complex typing, but runtime is safe
193
+ const result = await hook(...pendingArgs);
194
+
195
+ // Clear references to allow garbage collection
196
+ pendingArgs = null;
197
+ timeoutId = null;
198
+
199
+ resolve(result);
200
+ }
201
+ }, delayMs);
202
+ });
203
+ }) as HookFunction<T>;
204
+ }
205
+
206
+ /**
207
+ * Create a throttled version of a hook (ensures minimum time between calls)
208
+ */
209
+ export function throttleHook<T extends keyof FlowHooks>(
210
+ hook: HookFunction<T>,
211
+ intervalMs: number
212
+ ): HookFunction<T> {
213
+ let lastCallTime = 0;
214
+
215
+ return (async (...args: unknown[]) => {
216
+ const now = Date.now();
217
+ const timeSinceLastCall = now - lastCallTime;
218
+
219
+ if (timeSinceLastCall >= intervalMs) {
220
+ lastCallTime = now;
221
+ // @ts-expect-error - complex typing, but runtime is safe
222
+ return await hook(...args);
223
+ }
224
+ }) as HookFunction<T>;
225
+ }
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Dynamic buttons utilities - generate button options based on historical data
3
+ */
4
+
5
+ import { promises as fs } from "node:fs";
6
+ import path from "node:path";
7
+ import os from "node:os";
8
+ import type { FlowHooks, FlowSession, FlowStep, Button } from "../types.js";
9
+ import type { DynamicButtonsConfig, ButtonStrategy } from "./types.js";
10
+ import { querySheetHistory } from "./google-sheets.js";
11
+
12
+ /**
13
+ * Create a hook that generates dynamic buttons based on historical data.
14
+ * Returns an onStepRender hook function.
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * export default {
19
+ * onStepRender: createDynamicButtons({
20
+ * spreadsheetId: '1ABC...xyz',
21
+ * variable: 'reps',
22
+ * strategy: 'centered',
23
+ * buttonCount: 5,
24
+ * step: 5
25
+ * })
26
+ * };
27
+ * ```
28
+ */
29
+ export function createDynamicButtons(
30
+ config: DynamicButtonsConfig
31
+ ): NonNullable<FlowHooks["onStepRender"]> {
32
+ const {
33
+ spreadsheetId,
34
+ historyFile,
35
+ variable,
36
+ strategy,
37
+ buttonCount = 5,
38
+ step = 5,
39
+ } = config;
40
+
41
+ return async (flowStep: FlowStep, session: FlowSession): Promise<FlowStep> => {
42
+ // Only modify steps that capture the target variable
43
+ if (flowStep.capture !== variable) {
44
+ return flowStep;
45
+ }
46
+
47
+ try {
48
+ // Load historical data
49
+ let history: Array<Record<string, unknown>> = [];
50
+
51
+ if (spreadsheetId) {
52
+ // Load from Google Sheets
53
+ history = await querySheetHistory(spreadsheetId, "Sheet1", {
54
+ flowName: session.flowName,
55
+ userId: session.senderId,
56
+ });
57
+ } else if (historyFile) {
58
+ // Load from local .jsonl file
59
+ history = await loadHistoryFromFile(historyFile);
60
+ }
61
+
62
+ // Calculate average for the target variable
63
+ const avg = await getRecentAverage(variable, history, 10);
64
+
65
+ if (avg === null || Number.isNaN(avg)) {
66
+ // No history or invalid data, return step unchanged
67
+ return flowStep;
68
+ }
69
+
70
+ // Generate button range based on strategy
71
+ const values = generateButtonRange(avg, buttonCount, step, strategy);
72
+
73
+ // Convert to button objects
74
+ const buttons: Button[] = values.map((val) => ({
75
+ text: String(val),
76
+ value: val,
77
+ }));
78
+
79
+ // Return modified step with dynamic buttons
80
+ return {
81
+ ...flowStep,
82
+ buttons,
83
+ };
84
+ } catch (error) {
85
+ // Enhanced error message with context
86
+ const errorMessage = error instanceof Error ? error.message : String(error);
87
+ const dataSource = spreadsheetId
88
+ ? `Google Sheets (${spreadsheetId})`
89
+ : historyFile
90
+ ? `local file (${historyFile})`
91
+ : "unknown source";
92
+ console.error(
93
+ `Failed to generate dynamic buttons for variable "${variable}" from ${dataSource}: ${errorMessage}. ` +
94
+ `Using original step without dynamic buttons.`
95
+ );
96
+ // Return step unchanged on error
97
+ return flowStep;
98
+ }
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Get the recent average value for a variable from historical data.
104
+ * Returns null if no valid data found.
105
+ *
106
+ * @param variable - The variable name to calculate average for
107
+ * @param history - Array of historical session data
108
+ * @param count - Number of recent values to average (default: 10)
109
+ * @param precision - Number of decimal places (default: 0 for integers)
110
+ * @returns Average value rounded to specified precision, or null if no data
111
+ *
112
+ * @example
113
+ * ```ts
114
+ * // Integer average (default)
115
+ * const avgReps = await getRecentAverage('reps', history, 10);
116
+ *
117
+ * // Decimal average for weight tracking
118
+ * const avgWeight = await getRecentAverage('weight', history, 10, 1); // 155.5 lbs
119
+ * ```
120
+ */
121
+ export async function getRecentAverage(
122
+ variable: string,
123
+ history: Array<Record<string, unknown>>,
124
+ count = 10,
125
+ precision = 0
126
+ ): Promise<number | null> {
127
+ // Filter for entries with the target variable
128
+ const values = history
129
+ .map((entry) => entry[variable])
130
+ .filter((val): val is number | string => val !== undefined && val !== null)
131
+ .map((val) => (typeof val === "number" ? val : Number(val)))
132
+ .filter((val) => !Number.isNaN(val));
133
+
134
+ if (values.length === 0) {
135
+ return null;
136
+ }
137
+
138
+ // Take most recent N values
139
+ const recent = values.slice(-count);
140
+
141
+ // Calculate average
142
+ const sum = recent.reduce((acc, val) => acc + val, 0);
143
+ const average = sum / recent.length;
144
+
145
+ // Round to specified precision
146
+ return precision === 0
147
+ ? Math.round(average)
148
+ : Number(average.toFixed(precision));
149
+ }
150
+
151
+ /**
152
+ * Generate a range of button values based on a center point and strategy.
153
+ *
154
+ * @param center - The center value (typically the historical average)
155
+ * @param count - Number of buttons to generate
156
+ * @param step - Increment between buttons
157
+ * @param strategy - Button generation strategy
158
+ * @param minValue - Optional minimum value (no minimum by default)
159
+ *
160
+ * @example
161
+ * ```ts
162
+ * // Centered: [10, 15, 20, 25, 30] (center=20, count=5, step=5)
163
+ * const buttons = generateButtonRange(20, 5, 5, 'centered');
164
+ *
165
+ * // Progressive: [20, 22, 24, 26, 28] (increasing difficulty)
166
+ * const buttons = generateButtonRange(20, 5, 2, 'progressive');
167
+ *
168
+ * // Range: [15, 20, 25, 30, 35] (centered with larger steps)
169
+ * const buttons = generateButtonRange(25, 5, 5, 'range');
170
+ *
171
+ * // With minimum value (no negatives): [0, 5, 10, 15, 20]
172
+ * const buttons = generateButtonRange(10, 5, 5, 'centered', 0);
173
+ *
174
+ * // Temperature tracking (allows negatives): [-10, -5, 0, 5, 10]
175
+ * const buttons = generateButtonRange(0, 5, 5, 'centered');
176
+ * ```
177
+ */
178
+ export function generateButtonRange(
179
+ center: number,
180
+ count: number,
181
+ step: number,
182
+ strategy: ButtonStrategy = "centered",
183
+ minValue?: number
184
+ ): number[] {
185
+ const buttons: number[] = [];
186
+
187
+ switch (strategy) {
188
+ case "centered": {
189
+ // Generate buttons centered around the average
190
+ // For count=5: [avg-2*step, avg-step, avg, avg+step, avg+2*step]
191
+ const offset = Math.floor(count / 2);
192
+ for (let i = -offset; i <= offset; i++) {
193
+ if (buttons.length < count) {
194
+ const value = center + i * step;
195
+ buttons.push(minValue !== undefined ? Math.max(minValue, value) : value);
196
+ }
197
+ }
198
+ break;
199
+ }
200
+
201
+ case "progressive": {
202
+ // Generate buttons with increasing values (progressive difficulty)
203
+ // Start at previous average, then increase
204
+ for (let i = 0; i < count; i++) {
205
+ const value = center + i * step;
206
+ buttons.push(minValue !== undefined ? Math.max(minValue, value) : value);
207
+ }
208
+ break;
209
+ }
210
+
211
+ case "range": {
212
+ // Generate evenly-spaced range
213
+ // Similar to centered but extends beyond average
214
+ const offset = Math.floor(count / 2);
215
+ for (let i = -offset; i <= offset; i++) {
216
+ if (buttons.length < count) {
217
+ const value = center + i * step;
218
+ buttons.push(minValue !== undefined ? Math.max(minValue, value) : value);
219
+ }
220
+ }
221
+ break;
222
+ }
223
+
224
+ default:
225
+ throw new Error(`Unknown button strategy: ${strategy}`);
226
+ }
227
+
228
+ return buttons;
229
+ }
230
+
231
+ /**
232
+ * Load history from a local .jsonl file
233
+ */
234
+ async function loadHistoryFromFile(
235
+ filePath: string
236
+ ): Promise<Array<Record<string, unknown>>> {
237
+ try {
238
+ // Expand ~ to home directory
239
+ const expandedPath = filePath.startsWith("~")
240
+ ? path.join(os.homedir(), filePath.slice(1))
241
+ : filePath;
242
+
243
+ // Read file
244
+ const content = await fs.readFile(expandedPath, "utf-8");
245
+
246
+ // Parse JSONL (one JSON object per line)
247
+ const lines = content.trim().split("\n");
248
+ const history: Array<Record<string, unknown>> = [];
249
+
250
+ for (const line of lines) {
251
+ if (line.trim()) {
252
+ try {
253
+ const entry = JSON.parse(line) as Record<string, unknown>;
254
+ history.push(entry);
255
+ } catch (error) {
256
+ console.warn("Failed to parse JSONL line:", line, error);
257
+ }
258
+ }
259
+ }
260
+
261
+ return history;
262
+ } catch (error) {
263
+ // File doesn't exist or can't be read
264
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
265
+ return []; // No history yet
266
+ }
267
+ throw error;
268
+ }
269
+ }