@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.
- package/README.md +54 -14
- package/package.json +17 -4
- package/src/hooks/README.md +437 -0
- package/src/hooks/common.ts +225 -0
- package/src/hooks/dynamic-buttons.ts +269 -0
- package/src/hooks/google-sheets.ts +326 -0
- package/src/hooks/index.ts +45 -0
- package/src/hooks/scheduling.ts +259 -0
- package/src/hooks/types.ts +76 -0
|
@@ -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
|
+
}
|