automify 0.2.0 → 0.3.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,564 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
2
+
3
+ import { AutomifyError } from "./errors.js";
4
+ import { jsonOutput } from "./output.js";
5
+ import { startScreenRecording } from "./screen-recording.js";
6
+
7
+ const DEFAULT_TASK_MODE = "single";
8
+ const ASSERTION_OUTPUT = jsonOutput("task_assertion", {
9
+ passed: "boolean",
10
+ reason: "string"
11
+ });
12
+
13
+ export class AutomifyTask {
14
+ constructor(automify, options = {}) {
15
+ if (!automify || typeof automify.do !== "function") {
16
+ throw new AutomifyError("AutomifyTask requires an Automify runner.");
17
+ }
18
+
19
+ const taskOptions = normalizeTaskOptions(options);
20
+ this.automify = automify;
21
+ this.steps = [];
22
+ this.mode = taskOptions.mode;
23
+ this.runOptions = taskOptions.runOptions;
24
+ }
25
+
26
+ addStep(instruction, options = {}) {
27
+ this.#add("step", instruction, options);
28
+ return this;
29
+ }
30
+
31
+ addAct(instruction, options = {}) {
32
+ return this.addStep(instruction, options);
33
+ }
34
+
35
+ act(instruction, options = {}) {
36
+ return this.addAct(instruction, options);
37
+ }
38
+
39
+ step(instruction, options = {}) {
40
+ return this.addStep(instruction, options);
41
+ }
42
+
43
+ addWait(conditionOrMs = "the current screen is ready", options = {}) {
44
+ if (typeof conditionOrMs === "number") {
45
+ return this.addPause(conditionOrMs, options);
46
+ }
47
+ this.#add("wait", formatWaitInstruction(conditionOrMs), options);
48
+ return this;
49
+ }
50
+
51
+ addWaitFor(condition = "the current screen is ready", options = {}) {
52
+ return this.addWait(condition, options);
53
+ }
54
+
55
+ waitFor(condition, options = {}) {
56
+ return this.addWaitFor(condition, options);
57
+ }
58
+
59
+ wait(conditionOrMs, options = {}) {
60
+ return this.addWait(conditionOrMs, options);
61
+ }
62
+
63
+ addPause(ms, options = {}) {
64
+ const pauseMs = normalizePauseMs(ms);
65
+ this.#add("pause", formatPauseInstruction(pauseMs), options, { pauseMs });
66
+ return this;
67
+ }
68
+
69
+ pause(ms, options = {}) {
70
+ return this.addPause(ms, options);
71
+ }
72
+
73
+ addObserve(instruction, options = {}) {
74
+ this.#add("observe", instruction, options);
75
+ return this;
76
+ }
77
+
78
+ observe(instruction, options = {}) {
79
+ return this.addObserve(instruction, options);
80
+ }
81
+
82
+ addExtract(instruction, options = {}) {
83
+ this.#add("extract", instruction, options, normalizeExtractOptions(options));
84
+ return this;
85
+ }
86
+
87
+ extract(instruction, options = {}) {
88
+ return this.addExtract(instruction, options);
89
+ }
90
+
91
+ addAssert(instruction, options = {}) {
92
+ this.#add("assert", instruction, options);
93
+ return this;
94
+ }
95
+
96
+ assert(instruction, options = {}) {
97
+ return this.addAssert(instruction, options);
98
+ }
99
+
100
+ addData(data) {
101
+ this.runOptions.data = {
102
+ ...(isPlainObject(this.runOptions.data) ? this.runOptions.data : {}),
103
+ ...(isPlainObject(data) ? data : { value: data })
104
+ };
105
+ return this;
106
+ }
107
+
108
+ withData(data) {
109
+ this.runOptions.data = data;
110
+ return this;
111
+ }
112
+
113
+ withOptions(options = {}) {
114
+ if (!isPlainObject(options)) {
115
+ throw new AutomifyError("task options must be an object.");
116
+ }
117
+ const taskOptions = normalizeTaskOptions(options, this.mode);
118
+ this.mode = taskOptions.mode;
119
+ this.runOptions = {
120
+ ...this.runOptions,
121
+ ...taskOptions.runOptions,
122
+ data:
123
+ this.runOptions.data &&
124
+ taskOptions.runOptions.data &&
125
+ isPlainObject(this.runOptions.data) &&
126
+ isPlainObject(taskOptions.runOptions.data)
127
+ ? { ...this.runOptions.data, ...taskOptions.runOptions.data }
128
+ : (taskOptions.runOptions.data ?? this.runOptions.data)
129
+ };
130
+ return this;
131
+ }
132
+
133
+ toInstruction() {
134
+ if (this.steps.length === 0) {
135
+ throw new AutomifyError("task must include at least one step.");
136
+ }
137
+
138
+ return [
139
+ "Follow these steps in order. Complete each step before starting the next one.",
140
+ "",
141
+ ...this.steps.map((step, index) => `${index + 1}. ${formatStep(step)}`)
142
+ ].join("\n");
143
+ }
144
+
145
+ async run(options = {}) {
146
+ const taskOptions = mergeTaskOptions(
147
+ { mode: this.mode, runOptions: this.runOptions },
148
+ normalizeTaskOptions(options, this.mode)
149
+ );
150
+ if (taskOptions.mode === "sequential") {
151
+ return this.#runSequential(taskOptions.runOptions);
152
+ }
153
+ return this.automify.do(this.toInstruction(), applyExtractOutput(this.steps, taskOptions.runOptions));
154
+ }
155
+
156
+ async do(options = {}) {
157
+ return this.run(options);
158
+ }
159
+
160
+ #add(type, instruction, options = {}, meta = {}) {
161
+ if (typeof instruction !== "string" || instruction.trim() === "") {
162
+ throw new AutomifyError(`${type} instruction must be a non-empty string.`);
163
+ }
164
+ if (!isPlainObject(options)) {
165
+ throw new AutomifyError(`${type} options must be an object.`);
166
+ }
167
+
168
+ this.steps.push({
169
+ type,
170
+ instruction: instruction.trim(),
171
+ label: typeof options.label === "string" && options.label.trim() ? options.label.trim() : undefined,
172
+ notes: typeof options.notes === "string" && options.notes.trim() ? options.notes.trim() : undefined,
173
+ ...meta
174
+ });
175
+ }
176
+
177
+ async #runSequential(runOptions) {
178
+ assertHasSteps(this.steps);
179
+ assertSequentialExtracts(this.steps, runOptions);
180
+
181
+ const taskSteps = [];
182
+ const modelSteps = [];
183
+ const extracts = {};
184
+ let directParsed;
185
+ let lastResult = null;
186
+ let recording = null;
187
+
188
+ try {
189
+ recording = await this.#startSequentialRecording(runOptions);
190
+ const childBaseOptions = recording ? suppressChildRecording(runOptions) : runOptions;
191
+ const finalModelStepIndex = findFinalModelStepIndex(this.steps);
192
+
193
+ for (let index = 0; index < this.steps.length; index += 1) {
194
+ const step = this.steps[index];
195
+ const startedAt = Date.now();
196
+
197
+ if (step.type === "pause") {
198
+ await delay(step.pauseMs);
199
+ taskSteps.push(
200
+ taskStepRecord(step, index, {
201
+ status: "succeeded",
202
+ durationMs: Date.now() - startedAt
203
+ })
204
+ );
205
+ continue;
206
+ }
207
+
208
+ const output = outputForSequentialStep(step, runOptions, index === finalModelStepIndex);
209
+ const stepRunOptions = output ? { ...childBaseOptions, output } : withoutOutput(childBaseOptions);
210
+ let result;
211
+ try {
212
+ result = await this.automify.do(
213
+ formatSequentialStepInstruction(step, index, this.steps.length),
214
+ stepRunOptions
215
+ );
216
+ } catch (error) {
217
+ error.taskSteps = [
218
+ ...taskSteps,
219
+ taskStepRecord(step, index, {
220
+ status: "failed",
221
+ durationMs: Date.now() - startedAt,
222
+ error: error.message
223
+ })
224
+ ];
225
+ throw error;
226
+ }
227
+ lastResult = result;
228
+ if (Array.isArray(result.steps)) modelSteps.push(...result.steps);
229
+
230
+ if (step.type === "assert") {
231
+ const assertion = normalizeAssertionResult(result.parsed);
232
+ if (assertion.passed !== true) {
233
+ const error = new AutomifyError(`task assertion failed: ${assertion.reason || step.instruction}`);
234
+ error.taskSteps = [
235
+ ...taskSteps,
236
+ taskStepRecord(step, index, {
237
+ status: "failed",
238
+ durationMs: Date.now() - startedAt,
239
+ responseId: result.response?.id,
240
+ text: result.text,
241
+ parsed: result.parsed,
242
+ modelSteps: Array.isArray(result.steps) ? result.steps.length : 0,
243
+ error: assertion.reason || step.instruction
244
+ })
245
+ ];
246
+ throw error;
247
+ }
248
+ }
249
+
250
+ if (step.extract && "parsed" in result) {
251
+ if (step.extract.key) {
252
+ extracts[step.extract.key] = result.parsed;
253
+ } else {
254
+ directParsed = result.parsed;
255
+ }
256
+ }
257
+
258
+ taskSteps.push(
259
+ taskStepRecord(step, index, {
260
+ status: "succeeded",
261
+ durationMs: Date.now() - startedAt,
262
+ responseId: result.response?.id,
263
+ text: result.text,
264
+ parsed: result.parsed,
265
+ modelSteps: Array.isArray(result.steps) ? result.steps.length : 0
266
+ })
267
+ );
268
+ }
269
+
270
+ const result = buildSequentialResult(lastResult, taskSteps, modelSteps, extracts, directParsed);
271
+ if (recording) {
272
+ const recordingResult = await recording.stop({ response: result.response, steps: modelSteps });
273
+ if (recordingResult) result.recording = recordingResult;
274
+ }
275
+ return result;
276
+ } catch (error) {
277
+ if (recording) {
278
+ await recording.stop({ force: true }).catch(() => {});
279
+ }
280
+ throw error;
281
+ }
282
+ }
283
+
284
+ async #startSequentialRecording(runOptions) {
285
+ const recordingInput = screenRecordingInputForTask(runOptions, this.automify);
286
+ if (!recordingInput || typeof this.automify.computer?.screenshot !== "function") return null;
287
+
288
+ return startScreenRecording(recordingInput, {
289
+ instruction: this.toInstruction(),
290
+ data: runOptions.data,
291
+ captureFrame: (context) => this.automify.computer.screenshot(context)
292
+ });
293
+ }
294
+ }
295
+
296
+ export function createTask(automify, options = {}) {
297
+ return new AutomifyTask(automify, options);
298
+ }
299
+
300
+ function formatWaitInstruction(value) {
301
+ if (typeof value === "string" && value.trim()) {
302
+ return `Wait until ${value.trim()}.`;
303
+ }
304
+ return "Wait until the current screen is ready.";
305
+ }
306
+
307
+ function normalizePauseMs(value) {
308
+ const ms = Number(value);
309
+ if (!Number.isFinite(ms)) {
310
+ throw new AutomifyError("pause duration must be a finite number of milliseconds.");
311
+ }
312
+ return Math.max(0, Math.round(ms));
313
+ }
314
+
315
+ function formatPauseInstruction(value) {
316
+ return `Wait for about ${normalizePauseMs(value)} ms before continuing.`;
317
+ }
318
+
319
+ function formatStep(step) {
320
+ const prefix = step.type === "step" ? "" : `${step.type}: `;
321
+ const label = step.label ? `[${step.label}] ` : "";
322
+ const notes = step.notes ? ` Notes: ${step.notes}` : "";
323
+ return `${label}${prefix}${step.instruction}${notes}`;
324
+ }
325
+
326
+ function mergeRunOptions(base, override) {
327
+ if (!isPlainObject(override) || Object.keys(override).length === 0) return base;
328
+ return {
329
+ ...base,
330
+ ...override,
331
+ data:
332
+ base.data && override.data && isPlainObject(base.data) && isPlainObject(override.data)
333
+ ? { ...base.data, ...override.data }
334
+ : (override.data ?? base.data)
335
+ };
336
+ }
337
+
338
+ function normalizeTaskOptions(options, fallbackMode = DEFAULT_TASK_MODE) {
339
+ if (!isPlainObject(options)) {
340
+ throw new AutomifyError("task options must be an object.");
341
+ }
342
+
343
+ const { mode = fallbackMode, ...runOptions } = options;
344
+ return {
345
+ mode: normalizeTaskMode(mode),
346
+ runOptions
347
+ };
348
+ }
349
+
350
+ function normalizeTaskMode(mode) {
351
+ if (mode == null) return DEFAULT_TASK_MODE;
352
+ if (mode === "single" || mode === "sequential") return mode;
353
+ throw new AutomifyError('task mode must be "single" or "sequential".');
354
+ }
355
+
356
+ function mergeTaskOptions(base, override) {
357
+ return {
358
+ mode: override.mode ?? base.mode,
359
+ runOptions: mergeRunOptions(base.runOptions, override.runOptions)
360
+ };
361
+ }
362
+
363
+ function isPlainObject(value) {
364
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
365
+ }
366
+
367
+ function normalizeExtractOptions(options) {
368
+ const output = outputFromExtractOptions(options);
369
+ if (!output) return {};
370
+
371
+ const key = typeof options.key === "string" && options.key.trim() ? options.key.trim() : undefined;
372
+ return {
373
+ extract: {
374
+ key,
375
+ output
376
+ }
377
+ };
378
+ }
379
+
380
+ function outputFromExtractOptions(options) {
381
+ if (isOutputFormat(options)) return options;
382
+ if (!isPlainObject(options)) return null;
383
+ if (isOutputFormat(options.output)) return options.output;
384
+
385
+ const shape = options.shape ?? options.schema;
386
+ if (shape == null) return null;
387
+
388
+ const key = typeof options.key === "string" && options.key.trim() ? options.key.trim() : undefined;
389
+ if (!key) {
390
+ throw new AutomifyError("extract shape options require a non-empty key.");
391
+ }
392
+
393
+ return jsonOutput(key, shape, {
394
+ description: options.description,
395
+ strict: options.strict,
396
+ parse: options.parse
397
+ });
398
+ }
399
+
400
+ function applyExtractOutput(steps, runOptions) {
401
+ const extracts = steps.map((step) => step.extract).filter(Boolean);
402
+ if (extracts.length === 0) return runOptions;
403
+
404
+ if (runOptions.output) {
405
+ throw new AutomifyError(
406
+ "task extract outputs cannot be combined with run output. Put the output on extract steps."
407
+ );
408
+ }
409
+
410
+ return {
411
+ ...runOptions,
412
+ output: outputForExtracts(extracts)
413
+ };
414
+ }
415
+
416
+ function assertHasSteps(steps) {
417
+ if (steps.length === 0) {
418
+ throw new AutomifyError("task must include at least one step.");
419
+ }
420
+ }
421
+
422
+ function assertSequentialExtracts(steps, runOptions) {
423
+ const extracts = steps.map((step) => step.extract).filter(Boolean);
424
+ if (extracts.length === 0) return;
425
+
426
+ if (runOptions.output) {
427
+ throw new AutomifyError(
428
+ "task extract outputs cannot be combined with run output. Put the output on extract steps."
429
+ );
430
+ }
431
+
432
+ if (extracts.length > 1) {
433
+ for (const extract of extracts) {
434
+ if (!extract.key) {
435
+ throw new AutomifyError("multiple task extract outputs require a key for each extract.");
436
+ }
437
+ }
438
+ }
439
+
440
+ const keys = new Set();
441
+ for (const extract of extracts) {
442
+ if (!extract.key) continue;
443
+ if (keys.has(extract.key)) {
444
+ throw new AutomifyError(`duplicate task extract key: ${extract.key}`);
445
+ }
446
+ keys.add(extract.key);
447
+ }
448
+ }
449
+
450
+ function findFinalModelStepIndex(steps) {
451
+ for (let index = steps.length - 1; index >= 0; index -= 1) {
452
+ if (steps[index].type !== "pause") return index;
453
+ }
454
+ return -1;
455
+ }
456
+
457
+ function outputForSequentialStep(step, runOptions, isFinalModelStep) {
458
+ if (step.type === "assert") return ASSERTION_OUTPUT;
459
+ if (step.extract) return step.extract.output;
460
+ if (isFinalModelStep) return runOptions.output;
461
+ return undefined;
462
+ }
463
+
464
+ function withoutOutput(options) {
465
+ if (!options.output) return options;
466
+ const { output, ...rest } = options;
467
+ return rest;
468
+ }
469
+
470
+ function suppressChildRecording(options) {
471
+ return { ...options, screenRecording: false };
472
+ }
473
+
474
+ function screenRecordingInputForTask(runOptions, automify) {
475
+ if (Object.hasOwn(runOptions, "screenRecording")) return runOptions.screenRecording;
476
+ if (Object.hasOwn(runOptions, "recording")) return runOptions.recording;
477
+ if (isPlainObject(runOptions.screenshots) && Object.hasOwn(runOptions.screenshots, "recording")) {
478
+ return runOptions.screenshots.recording;
479
+ }
480
+ return automify.screenRecording;
481
+ }
482
+
483
+ function formatSequentialStepInstruction(step, index, total) {
484
+ return [`Complete task step ${index + 1} of ${total}.`, "Do only this step, then stop.", "", formatStep(step)].join(
485
+ "\n"
486
+ );
487
+ }
488
+
489
+ function normalizeAssertionResult(parsed) {
490
+ if (!parsed || typeof parsed !== "object") {
491
+ return { passed: false, reason: "assertion result was not structured" };
492
+ }
493
+ return {
494
+ passed: parsed.passed === true,
495
+ reason: typeof parsed.reason === "string" ? parsed.reason : ""
496
+ };
497
+ }
498
+
499
+ function taskStepRecord(step, index, details = {}) {
500
+ return removeUndefined({
501
+ index,
502
+ type: step.type,
503
+ instruction: step.instruction,
504
+ label: step.label,
505
+ notes: step.notes,
506
+ ...details
507
+ });
508
+ }
509
+
510
+ function buildSequentialResult(lastResult, taskSteps, modelSteps, extracts, directParsed) {
511
+ const result = lastResult
512
+ ? { ...lastResult }
513
+ : {
514
+ response: { id: null, output: [] },
515
+ ok: true,
516
+ status: "succeeded",
517
+ completed: true,
518
+ stopReason: "done",
519
+ text: ""
520
+ };
521
+
522
+ result.steps = modelSteps;
523
+ result.taskSteps = taskSteps;
524
+
525
+ const extractKeys = Object.keys(extracts);
526
+ if (extractKeys.length > 0) {
527
+ result.extracts = extracts;
528
+ result.parsed = extracts;
529
+ } else if (directParsed !== undefined) {
530
+ result.parsed = directParsed;
531
+ }
532
+
533
+ return result;
534
+ }
535
+
536
+ function removeUndefined(object) {
537
+ return Object.fromEntries(Object.entries(object).filter(([, value]) => value !== undefined));
538
+ }
539
+
540
+ function outputForExtracts(extracts) {
541
+ if (extracts.length === 1 && !extracts[0].key) {
542
+ return extracts[0].output;
543
+ }
544
+
545
+ const properties = {};
546
+ for (const extract of extracts) {
547
+ if (!extract.key) {
548
+ throw new AutomifyError("multiple task extract outputs require a key for each extract.");
549
+ }
550
+ if (properties[extract.key]) {
551
+ throw new AutomifyError(`duplicate task extract key: ${extract.key}`);
552
+ }
553
+ if (extract.output.type !== "json_schema" || !isPlainObject(extract.output.schema)) {
554
+ throw new AutomifyError("keyed task extract outputs require json_schema output.");
555
+ }
556
+ properties[extract.key] = extract.output.schema;
557
+ }
558
+
559
+ return jsonOutput("task_extracts", properties);
560
+ }
561
+
562
+ function isOutputFormat(value) {
563
+ return Boolean(value && typeof value === "object" && typeof value.type === "string");
564
+ }
@@ -12,7 +12,9 @@ export async function prepareVirtualSharedFolder(options = {}, defaults = {}) {
12
12
  if (!requested) return null;
13
13
 
14
14
  const config = normalizeSharedFolder(requested);
15
- const containerPath = normalizeContainerPath(config.containerPath ?? defaults.containerPath ?? DEFAULT_CONTAINER_PATH);
15
+ const containerPath = normalizeContainerPath(
16
+ config.containerPath ?? defaults.containerPath ?? DEFAULT_CONTAINER_PATH
17
+ );
16
18
  const hostPath = config.hostPath
17
19
  ? resolve(config.hostPath)
18
20
  : await mkdtemp(join(tmpdir(), defaults.prefix ?? "automify-shared-"));