@relauts/spotcheck-service 1.0.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.
Files changed (124) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE +19 -0
  3. package/README.md +169 -0
  4. package/dist/api/auth.d.ts +11 -0
  5. package/dist/api/auth.js +32 -0
  6. package/dist/api/auth.js.map +1 -0
  7. package/dist/api/history.d.ts +46 -0
  8. package/dist/api/history.js +172 -0
  9. package/dist/api/history.js.map +1 -0
  10. package/dist/api/saved-run.d.ts +35 -0
  11. package/dist/api/saved-run.js +234 -0
  12. package/dist/api/saved-run.js.map +1 -0
  13. package/dist/api/saved.d.ts +33 -0
  14. package/dist/api/saved.js +209 -0
  15. package/dist/api/saved.js.map +1 -0
  16. package/dist/api/server.d.ts +23 -0
  17. package/dist/api/server.js +852 -0
  18. package/dist/api/server.js.map +1 -0
  19. package/dist/computer-use/actions.d.ts +14 -0
  20. package/dist/computer-use/actions.js +212 -0
  21. package/dist/computer-use/actions.js.map +1 -0
  22. package/dist/computer-use/args.d.ts +11 -0
  23. package/dist/computer-use/args.js +74 -0
  24. package/dist/computer-use/args.js.map +1 -0
  25. package/dist/computer-use/cli.d.ts +1 -0
  26. package/dist/computer-use/cli.js +8 -0
  27. package/dist/computer-use/cli.js.map +1 -0
  28. package/dist/computer-use/client.d.ts +3 -0
  29. package/dist/computer-use/client.js +68 -0
  30. package/dist/computer-use/client.js.map +1 -0
  31. package/dist/computer-use/coords.d.ts +5 -0
  32. package/dist/computer-use/coords.js +21 -0
  33. package/dist/computer-use/coords.js.map +1 -0
  34. package/dist/computer-use/history.d.ts +11 -0
  35. package/dist/computer-use/history.js +59 -0
  36. package/dist/computer-use/history.js.map +1 -0
  37. package/dist/computer-use/index.d.ts +17 -0
  38. package/dist/computer-use/index.js +75 -0
  39. package/dist/computer-use/index.js.map +1 -0
  40. package/dist/computer-use/keys.d.ts +3 -0
  41. package/dist/computer-use/keys.js +50 -0
  42. package/dist/computer-use/keys.js.map +1 -0
  43. package/dist/computer-use/loop.d.ts +22 -0
  44. package/dist/computer-use/loop.js +90 -0
  45. package/dist/computer-use/loop.js.map +1 -0
  46. package/dist/computer-use/safety.d.ts +15 -0
  47. package/dist/computer-use/safety.js +45 -0
  48. package/dist/computer-use/safety.js.map +1 -0
  49. package/dist/computer-use/screenshot.d.ts +5 -0
  50. package/dist/computer-use/screenshot.js +16 -0
  51. package/dist/computer-use/screenshot.js.map +1 -0
  52. package/dist/computer-use/system-prompt.d.ts +11 -0
  53. package/dist/computer-use/system-prompt.js +41 -0
  54. package/dist/computer-use/system-prompt.js.map +1 -0
  55. package/dist/computer-use/types.d.ts +83 -0
  56. package/dist/computer-use/types.js +31 -0
  57. package/dist/computer-use/types.js.map +1 -0
  58. package/dist/playwright/actions.d.ts +81 -0
  59. package/dist/playwright/actions.js +170 -0
  60. package/dist/playwright/actions.js.map +1 -0
  61. package/dist/playwright/chromium.d.ts +10 -0
  62. package/dist/playwright/chromium.js +27 -0
  63. package/dist/playwright/chromium.js.map +1 -0
  64. package/dist/playwright/index.d.ts +2 -0
  65. package/dist/playwright/index.js +64 -0
  66. package/dist/playwright/index.js.map +1 -0
  67. package/dist/playwright/processed.d.ts +11 -0
  68. package/dist/playwright/processed.js +183 -0
  69. package/dist/playwright/processed.js.map +1 -0
  70. package/dist/playwright/session.d.ts +28 -0
  71. package/dist/playwright/session.js +165 -0
  72. package/dist/playwright/session.js.map +1 -0
  73. package/dist/shared/config.d.ts +38 -0
  74. package/dist/shared/config.js +227 -0
  75. package/dist/shared/config.js.map +1 -0
  76. package/dist/shared/cost.d.ts +15 -0
  77. package/dist/shared/cost.js +40 -0
  78. package/dist/shared/cost.js.map +1 -0
  79. package/dist/shared/logger.d.ts +4 -0
  80. package/dist/shared/logger.js +10 -0
  81. package/dist/shared/logger.js.map +1 -0
  82. package/dist/shared/main-module.d.ts +1 -0
  83. package/dist/shared/main-module.js +19 -0
  84. package/dist/shared/main-module.js.map +1 -0
  85. package/dist/shared/models.d.ts +12 -0
  86. package/dist/shared/models.js +40 -0
  87. package/dist/shared/models.js.map +1 -0
  88. package/dist/shared/telemetry.d.ts +9 -0
  89. package/dist/shared/telemetry.js +43 -0
  90. package/dist/shared/telemetry.js.map +1 -0
  91. package/files/model.json +26 -0
  92. package/files/system_prompt.txt +15 -0
  93. package/openapi.yaml +901 -0
  94. package/package.json +60 -0
  95. package/relauts-spotcheck-service-config.example.json +19 -0
  96. package/src/api/auth.ts +40 -0
  97. package/src/api/history.ts +251 -0
  98. package/src/api/saved-run.ts +346 -0
  99. package/src/api/saved.ts +275 -0
  100. package/src/api/server.ts +1198 -0
  101. package/src/computer-use/actions.ts +273 -0
  102. package/src/computer-use/args.ts +97 -0
  103. package/src/computer-use/cli.ts +8 -0
  104. package/src/computer-use/client.ts +81 -0
  105. package/src/computer-use/coords.ts +26 -0
  106. package/src/computer-use/history.ts +80 -0
  107. package/src/computer-use/index.ts +121 -0
  108. package/src/computer-use/keys.ts +54 -0
  109. package/src/computer-use/loop.ts +140 -0
  110. package/src/computer-use/safety.ts +59 -0
  111. package/src/computer-use/screenshot.ts +20 -0
  112. package/src/computer-use/system-prompt.ts +65 -0
  113. package/src/computer-use/types.ts +126 -0
  114. package/src/playwright/actions.ts +332 -0
  115. package/src/playwright/chromium.ts +30 -0
  116. package/src/playwright/index.ts +77 -0
  117. package/src/playwright/processed.ts +253 -0
  118. package/src/playwright/session.ts +220 -0
  119. package/src/shared/config.ts +320 -0
  120. package/src/shared/cost.ts +58 -0
  121. package/src/shared/logger.ts +9 -0
  122. package/src/shared/main-module.ts +20 -0
  123. package/src/shared/models.ts +62 -0
  124. package/src/shared/telemetry.ts +63 -0
@@ -0,0 +1,273 @@
1
+ import { asBoolean, asIntent, asNonNegativeNumber, asPoint, asString, asStringList, formatIntentLabel } from "./args.js";
2
+ import { isBrowserActionName } from "./history.js";
3
+ import { mapGeminiKey, mapGeminiKeys, selectAllShortcut } from "./keys.js";
4
+ import {
5
+ isSafetyBlocked,
6
+ readSafetyDecision,
7
+ requiresSafetyConfirmation,
8
+ } from "./safety.js";
9
+ import { EMPTY_USAGE } from "../shared/cost.js";
10
+ import { logger } from "../shared/logger.js";
11
+ import {
12
+ AgentRunError,
13
+ type ActionResult,
14
+ type BrowserActionName,
15
+ type ComputerUsePage,
16
+ type ExecuteTurnResult,
17
+ type FunctionCallStep,
18
+ type FunctionResultStep,
19
+ type IntentListener,
20
+ type SafetyConfirmer,
21
+ type ScreenshotSaver,
22
+ type Sleeper,
23
+ } from "./types.js";
24
+
25
+ export interface ExecuteActionsOptions {
26
+ page: ComputerUsePage;
27
+ screenWidth: number;
28
+ screenHeight: number;
29
+ saveScreenshot: ScreenshotSaver;
30
+ confirmSafety: SafetyConfirmer;
31
+ sleep?: Sleeper;
32
+ selectAllKey?: string;
33
+ onIntent?: IntentListener;
34
+ signal?: AbortSignal;
35
+ }
36
+
37
+ type ActionHandler = (
38
+ args: Record<string, unknown>,
39
+ options: ExecuteActionsOptions,
40
+ ) => Promise<void>;
41
+
42
+ const defaultSleep: Sleeper = (ms) =>
43
+ new Promise((resolve) => {
44
+ setTimeout(resolve, ms);
45
+ });
46
+
47
+ function errorMessage(error: unknown): string {
48
+ return error instanceof Error ? error.message : String(error);
49
+ }
50
+
51
+ function scrollDeltas(
52
+ direction: string,
53
+ magnitude: number,
54
+ ): { deltaX: number; deltaY: number } {
55
+ switch (direction.toLowerCase()) {
56
+ case "up":
57
+ return { deltaX: 0, deltaY: -magnitude };
58
+ case "down":
59
+ return { deltaX: 0, deltaY: magnitude };
60
+ case "left":
61
+ return { deltaX: -magnitude, deltaY: 0 };
62
+ case "right":
63
+ return { deltaX: magnitude, deltaY: 0 };
64
+ default:
65
+ throw new Error(`direction must be up, down, left, or right`);
66
+ }
67
+ }
68
+
69
+ async function holdHotkey(page: ComputerUsePage, keys: string[]): Promise<void> {
70
+ for (const key of keys) {
71
+ await page.keyboard.down(key);
72
+ }
73
+
74
+ for (let index = keys.length - 1; index >= 0; index -= 1) {
75
+ const key = keys[index];
76
+ if (key) {
77
+ await page.keyboard.up(key);
78
+ }
79
+ }
80
+ }
81
+
82
+ const ACTION_HANDLERS: Record<BrowserActionName, ActionHandler> = {
83
+ async click(args, { page, screenWidth, screenHeight }) {
84
+ const point = asPoint(args, screenWidth, screenHeight);
85
+ await page.mouse.click(point.x, point.y);
86
+ },
87
+ async double_click(args, { page, screenWidth, screenHeight }) {
88
+ const point = asPoint(args, screenWidth, screenHeight);
89
+ await page.mouse.dblclick(point.x, point.y);
90
+ },
91
+ async triple_click(args, { page, screenWidth, screenHeight }) {
92
+ const point = asPoint(args, screenWidth, screenHeight);
93
+ await page.mouse.click(point.x, point.y, { clickCount: 3 });
94
+ },
95
+ async middle_click(args, { page, screenWidth, screenHeight }) {
96
+ const point = asPoint(args, screenWidth, screenHeight);
97
+ await page.mouse.click(point.x, point.y, { button: "middle" });
98
+ },
99
+ async right_click(args, { page, screenWidth, screenHeight }) {
100
+ const point = asPoint(args, screenWidth, screenHeight);
101
+ await page.mouse.click(point.x, point.y, { button: "right" });
102
+ },
103
+ async mouse_down(args, { page, screenWidth, screenHeight }) {
104
+ const point = asPoint(args, screenWidth, screenHeight);
105
+ await page.mouse.move(point.x, point.y);
106
+ await page.mouse.down();
107
+ },
108
+ async mouse_up(args, { page, screenWidth, screenHeight }) {
109
+ const point = asPoint(args, screenWidth, screenHeight);
110
+ await page.mouse.move(point.x, point.y);
111
+ await page.mouse.up();
112
+ },
113
+ async move(args, { page, screenWidth, screenHeight }) {
114
+ const point = asPoint(args, screenWidth, screenHeight);
115
+ await page.mouse.move(point.x, point.y);
116
+ },
117
+ async type(args, { page, selectAllKey }) {
118
+ const text = asString(args.text, "text");
119
+ await page.keyboard.press(selectAllKey ?? selectAllShortcut());
120
+ await page.keyboard.press("Backspace");
121
+ await page.keyboard.type(text);
122
+ if (asBoolean(args.press_enter, false)) {
123
+ await page.keyboard.press("Enter");
124
+ }
125
+ },
126
+ async drag_and_drop(args, { page, screenWidth, screenHeight }) {
127
+ const start = asPoint({ x: args.start_x, y: args.start_y }, screenWidth, screenHeight);
128
+ const end = asPoint({ x: args.end_x, y: args.end_y }, screenWidth, screenHeight);
129
+ await page.mouse.move(start.x, start.y);
130
+ await page.mouse.down();
131
+ await page.mouse.move(end.x, end.y);
132
+ await page.mouse.up();
133
+ },
134
+ async wait(args, { sleep }) {
135
+ const seconds = asNonNegativeNumber(args.seconds, "seconds", 1);
136
+ await (sleep ?? defaultSleep)(seconds * 1000);
137
+ },
138
+ async press_key(args, { page }) {
139
+ await page.keyboard.press(mapGeminiKey(asString(args.key, "key")));
140
+ },
141
+ async key_down(args, { page }) {
142
+ await page.keyboard.down(mapGeminiKey(asString(args.key, "key")));
143
+ },
144
+ async key_up(args, { page }) {
145
+ await page.keyboard.up(mapGeminiKey(asString(args.key, "key")));
146
+ },
147
+ async hotkey(args, { page }) {
148
+ await holdHotkey(page, mapGeminiKeys(asStringList(args.keys, "keys")));
149
+ },
150
+ async take_screenshot() {
151
+ // Screenshot is captured after every action.
152
+ },
153
+ async scroll(args, { page, screenWidth, screenHeight }) {
154
+ const point = asPoint(args, screenWidth, screenHeight);
155
+ const magnitude = asNonNegativeNumber(args.magnitude_in_pixels, "magnitude_in_pixels", 300);
156
+ const { deltaX, deltaY } = scrollDeltas(asString(args.direction, "direction"), magnitude);
157
+ await page.mouse.move(point.x, point.y);
158
+ await page.mouse.wheel(deltaX, deltaY);
159
+ },
160
+ async go_back(_args, { page }) {
161
+ await page.goBack();
162
+ },
163
+ async navigate(args, { page }) {
164
+ await page.goto(asString(args.url, "url"));
165
+ },
166
+ async go_forward(_args, { page }) {
167
+ await page.goForward();
168
+ },
169
+ };
170
+
171
+ export async function executeFunctionCalls(
172
+ calls: FunctionCallStep[],
173
+ options: ExecuteActionsOptions,
174
+ ): Promise<ExecuteTurnResult> {
175
+ const results: ActionResult[] = [];
176
+ let terminated = false;
177
+ let lastScreenshotPath: string | undefined;
178
+
179
+ for (const call of calls) {
180
+ if (options.signal?.aborted) {
181
+ throw new AgentRunError("Agent stopped", EMPTY_USAGE);
182
+ }
183
+
184
+ if (terminated) {
185
+ results.push({
186
+ name: call.name,
187
+ callId: call.id,
188
+ payload: { error: "Skipped after safety denial" },
189
+ });
190
+ continue;
191
+ }
192
+
193
+ const payload: Record<string, unknown> = {};
194
+ const args = call.arguments;
195
+ const intent = asIntent(args);
196
+ logger.info(`Executing ${call.name} (Intent: ${intent})`);
197
+ await options.onIntent?.(formatIntentLabel(call.name, intent));
198
+
199
+ try {
200
+ const safety = readSafetyDecision(args);
201
+ if (isSafetyBlocked(safety)) {
202
+ throw new Error("Action blocked by safety policy");
203
+ }
204
+
205
+ if (requiresSafetyConfirmation(safety)) {
206
+ const confirmed = await options.confirmSafety(safety?.explanation ?? "", call.name);
207
+ if (!confirmed) {
208
+ terminated = true;
209
+ payload.error = "User denied safety confirmation";
210
+ await options.onIntent?.(formatIntentLabel(call.name, intent, true));
211
+ results.push({ name: call.name, callId: call.id, payload });
212
+ continue;
213
+ }
214
+
215
+ payload.safety_acknowledgement = "true";
216
+ }
217
+
218
+ if (!isBrowserActionName(call.name)) {
219
+ throw new Error(`Unsupported browser action: ${call.name}`);
220
+ }
221
+
222
+ await ACTION_HANDLERS[call.name](args, options);
223
+ } catch (error: unknown) {
224
+ if (error instanceof AgentRunError) {
225
+ throw error;
226
+ }
227
+
228
+ payload.error = errorMessage(error);
229
+ logger.error(`Failed to execute ${call.name}`, error);
230
+ await options.onIntent?.(formatIntentLabel(call.name, intent, true));
231
+ }
232
+
233
+ try {
234
+ lastScreenshotPath = await options.saveScreenshot(call.name);
235
+ } catch (error: unknown) {
236
+ payload.error = payload.error
237
+ ? `${String(payload.error)}; screenshot failed: ${errorMessage(error)}`
238
+ : `screenshot failed: ${errorMessage(error)}`;
239
+ logger.error(`Failed to screenshot after ${call.name}`, error);
240
+ }
241
+
242
+ results.push({ name: call.name, callId: call.id, payload });
243
+ }
244
+
245
+ return { results, terminated, lastScreenshotPath };
246
+ }
247
+
248
+ export function buildFunctionResults(
249
+ results: ActionResult[],
250
+ currentUrl: string,
251
+ screenshotBase64: string,
252
+ ): FunctionResultStep[] {
253
+ return results.map((item) => {
254
+ const hasError = typeof item.payload.error === "string";
255
+ return {
256
+ type: "function_result" as const,
257
+ name: item.name,
258
+ call_id: item.callId,
259
+ ...(hasError ? { is_error: true } : {}),
260
+ result: [
261
+ {
262
+ type: "text" as const,
263
+ text: JSON.stringify({ url: currentUrl, ...item.payload }),
264
+ },
265
+ {
266
+ type: "image" as const,
267
+ data: screenshotBase64,
268
+ mime_type: "image/webp" as const,
269
+ },
270
+ ],
271
+ };
272
+ });
273
+ }
@@ -0,0 +1,97 @@
1
+ import { denormalizeX, denormalizeY } from "./coords.js";
2
+
3
+ export function asFiniteNumber(value: unknown, field: string): number {
4
+ if (typeof value !== "number" || !Number.isFinite(value)) {
5
+ throw new Error(`${field} must be a finite number`);
6
+ }
7
+
8
+ return value;
9
+ }
10
+
11
+ export function asNonNegativeNumber(value: unknown, field: string, fallback: number): number {
12
+ if (value === undefined) {
13
+ return fallback;
14
+ }
15
+
16
+ const parsed = asFiniteNumber(value, field);
17
+ if (parsed < 0) {
18
+ throw new Error(`${field} must be >= 0`);
19
+ }
20
+
21
+ return parsed;
22
+ }
23
+
24
+ export function asString(value: unknown, field: string): string {
25
+ if (typeof value !== "string") {
26
+ throw new Error(`${field} must be a string`);
27
+ }
28
+
29
+ return value;
30
+ }
31
+
32
+ export function asBoolean(value: unknown, fallback: boolean): boolean {
33
+ if (value === undefined) {
34
+ return fallback;
35
+ }
36
+
37
+ if (typeof value === "boolean") {
38
+ return value;
39
+ }
40
+
41
+ if (value === "true" || value === 1) {
42
+ return true;
43
+ }
44
+
45
+ if (value === "false" || value === 0) {
46
+ return false;
47
+ }
48
+
49
+ return fallback;
50
+ }
51
+
52
+ export function asStringList(value: unknown, field: string): string[] {
53
+ if (typeof value === "string") {
54
+ const keys = value
55
+ .split("+")
56
+ .map((key) => key.trim())
57
+ .filter((key) => key.length > 0);
58
+ if (keys.length === 0) {
59
+ throw new Error(`${field} must not be empty`);
60
+ }
61
+
62
+ return keys;
63
+ }
64
+
65
+ if (Array.isArray(value) && value.every((item) => typeof item === "string")) {
66
+ const keys = value.map((item) => item.trim()).filter((item) => item.length > 0);
67
+ if (keys.length === 0) {
68
+ throw new Error(`${field} must not be empty`);
69
+ }
70
+
71
+ return keys;
72
+ }
73
+
74
+ throw new Error(`${field} must be a string list`);
75
+ }
76
+
77
+ export function asPoint(
78
+ args: Record<string, unknown>,
79
+ screenWidth: number,
80
+ screenHeight: number,
81
+ ): { x: number; y: number } {
82
+ return {
83
+ x: denormalizeX(asFiniteNumber(args.x, "x"), screenWidth),
84
+ y: denormalizeY(asFiniteNumber(args.y, "y"), screenHeight),
85
+ };
86
+ }
87
+
88
+ export function asIntent(args: Record<string, unknown>): string {
89
+ return typeof args.intent === "string" && args.intent.trim() ? args.intent : "N/A";
90
+ }
91
+
92
+ export function formatIntentLabel(name: string, intent: string, failed = false): string {
93
+ const trimmed = intent.trim();
94
+ const hasIntent = trimmed.length > 0 && trimmed !== "N/A";
95
+ const base = hasIntent ? `${name} — ${trimmed}` : name;
96
+ return failed ? `${base} (failed)` : base;
97
+ }
@@ -0,0 +1,8 @@
1
+ export function parseAgentTask(argv: readonly string[]): string {
2
+ const task = argv.slice(2).join(" ").trim();
3
+ if (!task) {
4
+ throw new Error('Usage: npm run agent -- "<task>"');
5
+ }
6
+
7
+ return task;
8
+ }
@@ -0,0 +1,81 @@
1
+ import { GoogleGenAI } from "@google/genai";
2
+ import type { AppConfig } from "../shared/config.js";
3
+ import { parseUsage } from "../shared/cost.js";
4
+ import { cloneSteps } from "./history.js";
5
+ import { AgentRunError, type InteractionClient, type InteractionResponse, type InteractionStep } from "./types.js";
6
+
7
+ type GeminiInteractionInput = Parameters<GoogleGenAI["interactions"]["create"]>[0]["input"];
8
+
9
+ const SUMMARY_RESPONSE_FORMAT = {
10
+ type: "text",
11
+ mime_type: "application/json",
12
+ schema: {
13
+ type: "object",
14
+ required: ["action", "summary", "validations"],
15
+ properties: {
16
+ action: { type: "string", enum: ["summary"] },
17
+ summary: { type: "string" },
18
+ validations: {
19
+ type: "array",
20
+ items: {
21
+ type: "object",
22
+ required: ["check", "result", "observed"],
23
+ properties: {
24
+ check: { type: "string" },
25
+ result: { type: "string", enum: ["pass", "fail"] },
26
+ observed: { type: "string" },
27
+ },
28
+ },
29
+ },
30
+ },
31
+ },
32
+ } as const;
33
+
34
+ export function createGeminiInteractionClient(
35
+ config: AppConfig,
36
+ model = config.geminiModel,
37
+ systemInstruction?: string,
38
+ ): InteractionClient {
39
+ if (!config.geminiApiKey) {
40
+ throw new Error("GEMINI_API_KEY is required");
41
+ }
42
+
43
+ const ai = new GoogleGenAI({ apiKey: config.geminiApiKey });
44
+
45
+ return {
46
+ async create(input: InteractionStep[]): Promise<InteractionResponse> {
47
+ const interaction = await ai.interactions.create({
48
+ model,
49
+ store: false,
50
+ ...(systemInstruction ? { system_instruction: systemInstruction } : {}),
51
+ input: input as GeminiInteractionInput,
52
+ tools: [
53
+ {
54
+ type: "computer_use",
55
+ environment: "browser",
56
+ enable_prompt_injection_detection: false,
57
+ ...(config.geminiDisabledSafetyPolicies.length > 0
58
+ ? { disabled_safety_policies: [...config.geminiDisabledSafetyPolicies] }
59
+ : {}),
60
+ },
61
+ ],
62
+ generation_config: {
63
+ thinking_level: config.geminiThinkingLevel,
64
+ ...(config.geminiSeed !== undefined ? { seed: config.geminiSeed } : {}),
65
+ },
66
+ response_format: SUMMARY_RESPONSE_FORMAT,
67
+ });
68
+
69
+ const usage = parseUsage(interaction.usage);
70
+
71
+ if (interaction.status === "failed") {
72
+ throw new AgentRunError("Gemini interaction failed", usage);
73
+ }
74
+
75
+ return {
76
+ steps: cloneSteps(interaction.steps),
77
+ usage,
78
+ };
79
+ },
80
+ };
81
+ }
@@ -0,0 +1,26 @@
1
+ export const NORMALIZED_MAX = 1000;
2
+
3
+ export function clampNormalized(value: number): number {
4
+ if (!Number.isFinite(value)) {
5
+ throw new Error("Coordinate must be a finite number");
6
+ }
7
+
8
+ return Math.min(NORMALIZED_MAX, Math.max(0, value));
9
+ }
10
+
11
+ export function denormalize(value: number, screenSize: number): number {
12
+ if (!Number.isInteger(screenSize) || screenSize <= 0) {
13
+ throw new Error("Screen size must be a positive integer");
14
+ }
15
+
16
+ const pixel = Math.floor((clampNormalized(value) / NORMALIZED_MAX) * screenSize);
17
+ return Math.min(screenSize - 1, Math.max(0, pixel));
18
+ }
19
+
20
+ export function denormalizeX(x: number, screenWidth: number): number {
21
+ return denormalize(x, screenWidth);
22
+ }
23
+
24
+ export function denormalizeY(y: number, screenHeight: number): number {
25
+ return denormalize(y, screenHeight);
26
+ }
@@ -0,0 +1,80 @@
1
+ import { BROWSER_ACTION_NAMES, type BrowserActionName, type InteractionStep } from "./types.js";
2
+
3
+ const BROWSER_ACTION_SET = new Set<string>(BROWSER_ACTION_NAMES);
4
+ const OLD_SCREENSHOT_PLACEHOLDER = [{ type: "text", text: "[old screenshot removed]" }];
5
+
6
+ export function isBrowserActionName(name: string): name is BrowserActionName {
7
+ return BROWSER_ACTION_SET.has(name);
8
+ }
9
+
10
+ export function getFunctionCalls(steps: InteractionStep[]): Array<{
11
+ type: "function_call";
12
+ id: string;
13
+ name: string;
14
+ arguments: Record<string, unknown>;
15
+ }> {
16
+ return steps.flatMap((step) => {
17
+ if (step.type !== "function_call") {
18
+ return [];
19
+ }
20
+
21
+ if (!step.id || !step.name) {
22
+ return [];
23
+ }
24
+
25
+ return [
26
+ {
27
+ type: "function_call" as const,
28
+ id: step.id,
29
+ name: step.name,
30
+ arguments: step.arguments ?? {},
31
+ },
32
+ ];
33
+ });
34
+ }
35
+
36
+ export function extractModelText(steps: InteractionStep[]): string {
37
+ return steps
38
+ .filter((step) => step.type === "model_output")
39
+ .flatMap((step) => step.content ?? [])
40
+ .filter((block) => block.type === "text" && typeof block.text === "string")
41
+ .map((block) => block.text as string)
42
+ .join(" ")
43
+ .trim();
44
+ }
45
+
46
+ function hasImageResult(result: unknown): boolean {
47
+ return (
48
+ Array.isArray(result) &&
49
+ result.some((block) => typeof block === "object" && block !== null && (block as { type?: unknown }).type === "image")
50
+ );
51
+ }
52
+
53
+ export function cloneSteps(steps: unknown): InteractionStep[] {
54
+ return JSON.parse(JSON.stringify(steps)) as InteractionStep[];
55
+ }
56
+
57
+ export function compactOldScreenshots(history: InteractionStep[]): InteractionStep[] {
58
+ let keepLatest = true;
59
+
60
+ for (let index = history.length - 1; index >= 0; index -= 1) {
61
+ const item = history[index];
62
+ if (!item || item.type !== "function_result" || !hasImageResult(item.result)) {
63
+ continue;
64
+ }
65
+
66
+ if (keepLatest) {
67
+ keepLatest = false;
68
+ continue;
69
+ }
70
+
71
+ const textBlocks = Array.isArray(item.result)
72
+ ? item.result.filter(
73
+ (block) => typeof block === "object" && block !== null && (block as { type?: unknown }).type === "text",
74
+ )
75
+ : [];
76
+ item.result = textBlocks.length > 0 ? textBlocks : OLD_SCREENSHOT_PLACEHOLDER;
77
+ }
78
+
79
+ return history;
80
+ }
@@ -0,0 +1,121 @@
1
+ import { createGeminiInteractionClient } from "./client.js";
2
+ import { parseAgentTask } from "./cli.js";
3
+ import { runComputerUseLoop } from "./loop.js";
4
+ import { confirmSafetyInTerminal } from "./safety.js";
5
+ import { defaultSystemPromptVars, loadSystemPrompt } from "./system-prompt.js";
6
+ import { AgentRunError, type IntentListener, type SafetyConfirmer } from "./types.js";
7
+ import { createProcessedScreenshotSaver } from "../playwright/processed.js";
8
+ import {
9
+ BROWSER_SESSION_RESET_MESSAGE,
10
+ createBrowserSession,
11
+ type BrowserSession,
12
+ } from "../playwright/session.js";
13
+ import { loadConfig, requireGeminiApiKey, type AppConfig } from "../shared/config.js";
14
+ import { type TokenUsage } from "../shared/cost.js";
15
+ import { logger } from "../shared/logger.js";
16
+ import { isMainModule } from "../shared/main-module.js";
17
+
18
+ export { AgentRunError, BROWSER_SESSION_RESET_MESSAGE };
19
+
20
+ export interface AgentRunResult {
21
+ readonly text: string;
22
+ readonly sessionReset: boolean;
23
+ readonly usage: TokenUsage;
24
+ }
25
+
26
+ const agentSession = createBrowserSession();
27
+ let shuttingDown = false;
28
+
29
+ export async function closeAgentSession(): Promise<void> {
30
+ await agentSession.close();
31
+ }
32
+
33
+ async function shutdown(exitCode: number, reason: string): Promise<never> {
34
+ if (shuttingDown) {
35
+ process.exit(exitCode);
36
+ }
37
+
38
+ shuttingDown = true;
39
+ logger.info(reason);
40
+ await closeAgentSession();
41
+ process.exit(exitCode);
42
+ }
43
+
44
+ export interface AgentRunExtras {
45
+ readonly onIntent?: IntentListener;
46
+ readonly signal?: AbortSignal;
47
+ readonly confirmSafety?: SafetyConfirmer;
48
+ }
49
+
50
+ export async function runAgent(
51
+ task: string,
52
+ config: AppConfig = loadConfig(),
53
+ confirmSafety: SafetyConfirmer = confirmSafetyInTerminal,
54
+ session: BrowserSession = agentSession,
55
+ model = config.geminiModel,
56
+ extras: AgentRunExtras = {},
57
+ ): Promise<AgentRunResult> {
58
+ requireGeminiApiKey(config);
59
+
60
+ logger.info(
61
+ `Computer-use model=${model} thinking=${config.geminiThinkingLevel}` +
62
+ (config.geminiSeed !== undefined ? ` seed=${config.geminiSeed}` : ""),
63
+ );
64
+ const { page, sessionReset } = await session.ensurePage(config);
65
+
66
+ if (sessionReset) {
67
+ logger.info(BROWSER_SESSION_RESET_MESSAGE);
68
+ }
69
+
70
+ const systemInstruction = await loadSystemPrompt(
71
+ defaultSystemPromptVars(config.viewportWidth, config.viewportHeight),
72
+ );
73
+
74
+ const result = await runComputerUseLoop(
75
+ {
76
+ client: createGeminiInteractionClient(config, model, systemInstruction),
77
+ page,
78
+ saveScreenshot: createProcessedScreenshotSaver(page, config.processedDir),
79
+ confirmSafety: extras.confirmSafety ?? confirmSafety,
80
+ screenWidth: config.viewportWidth,
81
+ screenHeight: config.viewportHeight,
82
+ maxTurns: config.agentMaxTurns,
83
+ onIntent: extras.onIntent,
84
+ signal: extras.signal,
85
+ },
86
+ task,
87
+ );
88
+
89
+ logger.info(`Computer-use status=${result.status} turns=${result.turns}`);
90
+ return { text: result.finalText, sessionReset, usage: result.usage };
91
+ }
92
+
93
+ function registerShutdownHandlers(): void {
94
+ const onSignal = (signal: NodeJS.Signals): void => {
95
+ void shutdown(0, `Received ${signal}, shutting down`);
96
+ };
97
+
98
+ process.once("SIGINT", onSignal);
99
+ process.once("SIGTERM", onSignal);
100
+ }
101
+
102
+ async function waitUntilStopped(): Promise<void> {
103
+ logger.info("Task finished. Browser stays open until Ctrl+C");
104
+ await new Promise<void>(() => {
105
+ // Shutdown handlers call process.exit.
106
+ });
107
+ }
108
+
109
+ async function main(): Promise<void> {
110
+ registerShutdownHandlers();
111
+ const task = parseAgentTask(process.argv);
112
+ await runAgent(task);
113
+ await waitUntilStopped();
114
+ }
115
+
116
+ if (isMainModule(import.meta.url)) {
117
+ main().catch((error: unknown) => {
118
+ logger.error("Fatal error", error);
119
+ void shutdown(1, "Exiting after fatal error");
120
+ });
121
+ }