outcome-cli 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.
- package/README.md +261 -0
- package/package.json +95 -0
- package/src/agents/README.md +139 -0
- package/src/agents/adapters/anthropic.adapter.ts +166 -0
- package/src/agents/adapters/dalle.adapter.ts +145 -0
- package/src/agents/adapters/gemini.adapter.ts +134 -0
- package/src/agents/adapters/imagen.adapter.ts +106 -0
- package/src/agents/adapters/nano-banana.adapter.ts +129 -0
- package/src/agents/adapters/openai.adapter.ts +165 -0
- package/src/agents/adapters/veo.adapter.ts +130 -0
- package/src/agents/agent.schema.property.test.ts +379 -0
- package/src/agents/agent.schema.test.ts +148 -0
- package/src/agents/agent.schema.ts +263 -0
- package/src/agents/index.ts +60 -0
- package/src/agents/registered-agent.schema.ts +356 -0
- package/src/agents/registry.ts +97 -0
- package/src/agents/tournament-configs.property.test.ts +266 -0
- package/src/cli/README.md +145 -0
- package/src/cli/commands/define.ts +79 -0
- package/src/cli/commands/list.ts +46 -0
- package/src/cli/commands/logs.ts +83 -0
- package/src/cli/commands/run.ts +416 -0
- package/src/cli/commands/verify.ts +110 -0
- package/src/cli/index.ts +81 -0
- package/src/config/README.md +128 -0
- package/src/config/env.ts +262 -0
- package/src/config/index.ts +19 -0
- package/src/eval/README.md +318 -0
- package/src/eval/ai-judge.test.ts +435 -0
- package/src/eval/ai-judge.ts +368 -0
- package/src/eval/code-validators.ts +414 -0
- package/src/eval/evaluateOutcome.property.test.ts +1174 -0
- package/src/eval/evaluateOutcome.ts +591 -0
- package/src/eval/immigration-validators.ts +122 -0
- package/src/eval/index.ts +90 -0
- package/src/eval/judge-cache.ts +402 -0
- package/src/eval/tournament-validators.property.test.ts +439 -0
- package/src/eval/validators.property.test.ts +1118 -0
- package/src/eval/validators.ts +1199 -0
- package/src/eval/weighted-scorer.ts +285 -0
- package/src/index.ts +17 -0
- package/src/league/README.md +188 -0
- package/src/league/health-check.ts +353 -0
- package/src/league/index.ts +93 -0
- package/src/league/killAgent.ts +151 -0
- package/src/league/league.test.ts +1151 -0
- package/src/league/runLeague.ts +843 -0
- package/src/league/scoreAgent.ts +175 -0
- package/src/modules/omnibridge/__tests__/.gitkeep +1 -0
- package/src/modules/omnibridge/__tests__/auth-tunnel.property.test.ts +524 -0
- package/src/modules/omnibridge/__tests__/deterministic-logger.property.test.ts +965 -0
- package/src/modules/omnibridge/__tests__/ghost-api.property.test.ts +461 -0
- package/src/modules/omnibridge/__tests__/omnibridge-integration.test.ts +542 -0
- package/src/modules/omnibridge/__tests__/parallel-executor.property.test.ts +671 -0
- package/src/modules/omnibridge/__tests__/semantic-normalizer.property.test.ts +521 -0
- package/src/modules/omnibridge/__tests__/semantic-normalizer.test.ts +254 -0
- package/src/modules/omnibridge/__tests__/session-vault.property.test.ts +367 -0
- package/src/modules/omnibridge/__tests__/shadow-session.property.test.ts +523 -0
- package/src/modules/omnibridge/__tests__/triangulation-engine.property.test.ts +292 -0
- package/src/modules/omnibridge/__tests__/verification-engine.property.test.ts +769 -0
- package/src/modules/omnibridge/api/.gitkeep +1 -0
- package/src/modules/omnibridge/api/ghost-api.ts +1087 -0
- package/src/modules/omnibridge/auth/.gitkeep +1 -0
- package/src/modules/omnibridge/auth/auth-tunnel.ts +843 -0
- package/src/modules/omnibridge/auth/session-vault.ts +577 -0
- package/src/modules/omnibridge/core/.gitkeep +1 -0
- package/src/modules/omnibridge/core/semantic-normalizer.ts +702 -0
- package/src/modules/omnibridge/core/triangulation-engine.ts +530 -0
- package/src/modules/omnibridge/core/types.ts +610 -0
- package/src/modules/omnibridge/execution/.gitkeep +1 -0
- package/src/modules/omnibridge/execution/deterministic-logger.ts +629 -0
- package/src/modules/omnibridge/execution/parallel-executor.ts +542 -0
- package/src/modules/omnibridge/execution/shadow-session.ts +794 -0
- package/src/modules/omnibridge/index.ts +212 -0
- package/src/modules/omnibridge/omnibridge.ts +510 -0
- package/src/modules/omnibridge/verification/.gitkeep +1 -0
- package/src/modules/omnibridge/verification/verification-engine.ts +783 -0
- package/src/outcomes/README.md +75 -0
- package/src/outcomes/acquire-pilot-customer.ts +297 -0
- package/src/outcomes/code-delivery-outcomes.ts +89 -0
- package/src/outcomes/code-outcomes.ts +256 -0
- package/src/outcomes/code_review_battle.test.ts +135 -0
- package/src/outcomes/code_review_battle.ts +135 -0
- package/src/outcomes/cold_email_battle.ts +97 -0
- package/src/outcomes/content_creation_battle.ts +160 -0
- package/src/outcomes/f1_stem_opt_compliance.ts +61 -0
- package/src/outcomes/index.ts +107 -0
- package/src/outcomes/lead_gen_battle.test.ts +113 -0
- package/src/outcomes/lead_gen_battle.ts +99 -0
- package/src/outcomes/outcome.schema.property.test.ts +229 -0
- package/src/outcomes/outcome.schema.ts +187 -0
- package/src/outcomes/qualified_sales_interest.ts +118 -0
- package/src/outcomes/swarm_planner.property.test.ts +370 -0
- package/src/outcomes/swarm_planner.ts +96 -0
- package/src/outcomes/web_extraction.ts +234 -0
- package/src/runtime/README.md +220 -0
- package/src/runtime/agentRunner.test.ts +341 -0
- package/src/runtime/agentRunner.ts +746 -0
- package/src/runtime/claudeAdapter.ts +232 -0
- package/src/runtime/costTracker.ts +123 -0
- package/src/runtime/index.ts +34 -0
- package/src/runtime/modelAdapter.property.test.ts +305 -0
- package/src/runtime/modelAdapter.ts +144 -0
- package/src/runtime/openaiAdapter.ts +235 -0
- package/src/utils/README.md +122 -0
- package/src/utils/command-runner.ts +134 -0
- package/src/utils/cost-guard.ts +379 -0
- package/src/utils/errors.test.ts +290 -0
- package/src/utils/errors.ts +442 -0
- package/src/utils/index.ts +37 -0
- package/src/utils/logger.test.ts +361 -0
- package/src/utils/logger.ts +419 -0
- package/src/utils/output-parsers.ts +216 -0
|
@@ -0,0 +1,1087 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ghost-API
|
|
3
|
+
*
|
|
4
|
+
* Clean JSON interface that agents use to interact with websites.
|
|
5
|
+
* Agents never see HTML - they see structured endpoints and responses.
|
|
6
|
+
*
|
|
7
|
+
* Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
11
|
+
import type {
|
|
12
|
+
GoalDefinition,
|
|
13
|
+
SchemaMapping,
|
|
14
|
+
GhostResponse,
|
|
15
|
+
IntentDocument,
|
|
16
|
+
IntentElement,
|
|
17
|
+
ActionLogEntry,
|
|
18
|
+
} from '../core/types.js';
|
|
19
|
+
import type { SemanticNormalizer } from '../core/semantic-normalizer.js';
|
|
20
|
+
import type { TriangulationEngine } from '../core/triangulation-engine.js';
|
|
21
|
+
import type { DeterministicLogger } from '../execution/deterministic-logger.js';
|
|
22
|
+
import type { ShadowSessionOrchestrator } from '../execution/shadow-session.js';
|
|
23
|
+
|
|
24
|
+
// =============================================================================
|
|
25
|
+
// Types
|
|
26
|
+
// =============================================================================
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Configuration for the Ghost-API.
|
|
30
|
+
*/
|
|
31
|
+
export interface GhostApiConfig {
|
|
32
|
+
/** Semantic Normalizer for DOM → Intent conversion */
|
|
33
|
+
normalizer: SemanticNormalizer;
|
|
34
|
+
/** Triangulation Engine for self-healing element location */
|
|
35
|
+
triangulationEngine: TriangulationEngine;
|
|
36
|
+
/** Deterministic Logger for action logging */
|
|
37
|
+
logger: DeterministicLogger;
|
|
38
|
+
/** Shadow Session Orchestrator for browser sessions */
|
|
39
|
+
sessionOrchestrator?: ShadowSessionOrchestrator;
|
|
40
|
+
/** Base path for generated endpoints (default: '/omni-bridge') */
|
|
41
|
+
basePath?: string;
|
|
42
|
+
/** Maximum pages to handle for pagination (default: 10) */
|
|
43
|
+
maxPaginationPages?: number;
|
|
44
|
+
/** Timeout for goal execution in milliseconds (default: 30000) */
|
|
45
|
+
executionTimeoutMs?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Execution context for a Ghost-API call.
|
|
50
|
+
*/
|
|
51
|
+
export interface ExecutionContext {
|
|
52
|
+
/** Session ID for the execution */
|
|
53
|
+
sessionId: string;
|
|
54
|
+
/** Goal being executed */
|
|
55
|
+
goal: GoalDefinition;
|
|
56
|
+
/** Schema mapping for the goal */
|
|
57
|
+
schemaMapping: SchemaMapping;
|
|
58
|
+
/** Start time of execution */
|
|
59
|
+
startTime: number;
|
|
60
|
+
/** Actions performed count */
|
|
61
|
+
actionsPerformed: number;
|
|
62
|
+
/** Triangulation heals count */
|
|
63
|
+
triangulationHeals: number;
|
|
64
|
+
/** Extracted data */
|
|
65
|
+
extractedData: Record<string, unknown>[];
|
|
66
|
+
/** Current page number (for pagination) */
|
|
67
|
+
currentPage: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Result of a DOM action.
|
|
72
|
+
*/
|
|
73
|
+
export interface ActionResult {
|
|
74
|
+
/** Whether the action succeeded */
|
|
75
|
+
success: boolean;
|
|
76
|
+
/** Error message if failed */
|
|
77
|
+
error?: string;
|
|
78
|
+
/** Extracted data if applicable */
|
|
79
|
+
data?: Record<string, unknown>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Pagination detection result.
|
|
84
|
+
*/
|
|
85
|
+
export interface PaginationInfo {
|
|
86
|
+
/** Whether pagination is detected */
|
|
87
|
+
hasPagination: boolean;
|
|
88
|
+
/** Next page element if found */
|
|
89
|
+
nextPageElement?: IntentElement;
|
|
90
|
+
/** Total pages if detectable */
|
|
91
|
+
totalPages?: number;
|
|
92
|
+
/** Current page number */
|
|
93
|
+
currentPage: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Goal execution result.
|
|
98
|
+
*/
|
|
99
|
+
export interface GoalExecutionResult {
|
|
100
|
+
/** Whether execution succeeded */
|
|
101
|
+
success: boolean;
|
|
102
|
+
/** Ghost response if successful */
|
|
103
|
+
response?: GhostResponse;
|
|
104
|
+
/** Error message if failed */
|
|
105
|
+
error?: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// =============================================================================
|
|
109
|
+
// Schema Inference
|
|
110
|
+
// =============================================================================
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Infer a JSON schema from an Intent Document.
|
|
114
|
+
* Analyzes the document structure to generate a schema for the goal.
|
|
115
|
+
*/
|
|
116
|
+
export function inferSchemaFromDocument(
|
|
117
|
+
document: IntentDocument,
|
|
118
|
+
goal: GoalDefinition
|
|
119
|
+
): Record<string, unknown> {
|
|
120
|
+
const schema: Record<string, unknown> = {
|
|
121
|
+
type: 'object',
|
|
122
|
+
properties: {},
|
|
123
|
+
required: [],
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const properties = schema.properties as Record<string, unknown>;
|
|
127
|
+
const required = schema.required as string[];
|
|
128
|
+
|
|
129
|
+
// Analyze forms for input/output fields
|
|
130
|
+
for (const form of document.forms) {
|
|
131
|
+
for (const field of form.fields) {
|
|
132
|
+
const fieldName = sanitizeFieldName(field.name || field.label);
|
|
133
|
+
if (fieldName) {
|
|
134
|
+
properties[fieldName] = inferFieldSchema(field.type, field.label);
|
|
135
|
+
if (field.required) {
|
|
136
|
+
required.push(fieldName);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Analyze display elements for data extraction
|
|
143
|
+
const displayElements = document.elements.filter(el => el.role === 'display');
|
|
144
|
+
for (const element of displayElements) {
|
|
145
|
+
const fieldName = sanitizeFieldName(element.label);
|
|
146
|
+
if (fieldName && !properties[fieldName]) {
|
|
147
|
+
properties[fieldName] = {
|
|
148
|
+
type: 'string',
|
|
149
|
+
description: `Extracted from ${element.intentId}`,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// If goal has output schema, merge it
|
|
155
|
+
if (goal.outputSchema) {
|
|
156
|
+
return mergeSchemas(schema, goal.outputSchema);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return schema;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Sanitize a field name for use in JSON schema.
|
|
164
|
+
*/
|
|
165
|
+
function sanitizeFieldName(name: string): string {
|
|
166
|
+
if (!name) return '';
|
|
167
|
+
return name
|
|
168
|
+
.toLowerCase()
|
|
169
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
170
|
+
.replace(/^_+|_+$/g, '')
|
|
171
|
+
.slice(0, 50);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Infer field schema from input type.
|
|
176
|
+
*/
|
|
177
|
+
function inferFieldSchema(type: string, label: string): Record<string, unknown> {
|
|
178
|
+
const labelLower = label.toLowerCase();
|
|
179
|
+
|
|
180
|
+
switch (type) {
|
|
181
|
+
case 'email':
|
|
182
|
+
return { type: 'string', format: 'email' };
|
|
183
|
+
case 'number':
|
|
184
|
+
case 'range':
|
|
185
|
+
return { type: 'number' };
|
|
186
|
+
case 'checkbox':
|
|
187
|
+
return { type: 'boolean' };
|
|
188
|
+
case 'date':
|
|
189
|
+
return { type: 'string', format: 'date' };
|
|
190
|
+
case 'datetime-local':
|
|
191
|
+
return { type: 'string', format: 'date-time' };
|
|
192
|
+
case 'url':
|
|
193
|
+
return { type: 'string', format: 'uri' };
|
|
194
|
+
case 'tel':
|
|
195
|
+
return { type: 'string', pattern: '^[+]?[0-9\\s-]+$' };
|
|
196
|
+
default:
|
|
197
|
+
// Infer from label
|
|
198
|
+
if (labelLower.includes('email')) {
|
|
199
|
+
return { type: 'string', format: 'email' };
|
|
200
|
+
}
|
|
201
|
+
if (labelLower.includes('phone') || labelLower.includes('tel')) {
|
|
202
|
+
return { type: 'string', pattern: '^[+]?[0-9\\s-]+$' };
|
|
203
|
+
}
|
|
204
|
+
if (labelLower.includes('date')) {
|
|
205
|
+
return { type: 'string', format: 'date' };
|
|
206
|
+
}
|
|
207
|
+
if (labelLower.includes('url') || labelLower.includes('website')) {
|
|
208
|
+
return { type: 'string', format: 'uri' };
|
|
209
|
+
}
|
|
210
|
+
if (labelLower.includes('amount') || labelLower.includes('price') || labelLower.includes('total')) {
|
|
211
|
+
return { type: 'number' };
|
|
212
|
+
}
|
|
213
|
+
return { type: 'string' };
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Merge two JSON schemas.
|
|
219
|
+
*/
|
|
220
|
+
function mergeSchemas(
|
|
221
|
+
base: Record<string, unknown>,
|
|
222
|
+
override: Record<string, unknown>
|
|
223
|
+
): Record<string, unknown> {
|
|
224
|
+
const merged = { ...base };
|
|
225
|
+
|
|
226
|
+
if (override.properties && typeof override.properties === 'object') {
|
|
227
|
+
merged.properties = {
|
|
228
|
+
...(base.properties as Record<string, unknown> || {}),
|
|
229
|
+
...(override.properties as Record<string, unknown>),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (override.required && Array.isArray(override.required)) {
|
|
234
|
+
const baseRequired = (base.required as string[]) || [];
|
|
235
|
+
merged.required = [...new Set([...baseRequired, ...override.required])];
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return merged;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// =============================================================================
|
|
242
|
+
// Endpoint Generation
|
|
243
|
+
// =============================================================================
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Generate a clean endpoint path from a goal definition.
|
|
247
|
+
*/
|
|
248
|
+
export function generateEndpointPath(goal: GoalDefinition, basePath: string): string {
|
|
249
|
+
// Extract domain from target URL
|
|
250
|
+
let domain = 'unknown';
|
|
251
|
+
try {
|
|
252
|
+
const url = new URL(goal.targetUrl);
|
|
253
|
+
domain = url.hostname.replace(/^www\./, '').split('.')[0];
|
|
254
|
+
} catch {
|
|
255
|
+
// Use goal name if URL parsing fails
|
|
256
|
+
domain = goal.name.toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Convert goal name to kebab-case
|
|
260
|
+
const goalPath = goal.name
|
|
261
|
+
.toLowerCase()
|
|
262
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
263
|
+
.replace(/^-+|-+$/g, '');
|
|
264
|
+
|
|
265
|
+
return `${basePath}/${domain}/${goalPath}`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Estimate the number of actions needed to complete a goal.
|
|
270
|
+
*/
|
|
271
|
+
export function estimateActions(document: IntentDocument, goal: GoalDefinition): number {
|
|
272
|
+
let estimate = 1; // At least one action (navigation)
|
|
273
|
+
|
|
274
|
+
// Add actions for forms
|
|
275
|
+
for (const form of document.forms) {
|
|
276
|
+
estimate += form.fields.length; // One action per field
|
|
277
|
+
estimate += 1; // Submit action
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Add actions for navigation
|
|
281
|
+
estimate += document.navigation.primaryLinks.length > 0 ? 1 : 0;
|
|
282
|
+
|
|
283
|
+
// Add actions for data extraction
|
|
284
|
+
const displayElements = document.elements.filter(el => el.role === 'display');
|
|
285
|
+
estimate += Math.ceil(displayElements.length / 10); // Batch extraction
|
|
286
|
+
|
|
287
|
+
// Factor in goal complexity from description
|
|
288
|
+
const descLower = goal.description.toLowerCase();
|
|
289
|
+
if (descLower.includes('multiple') || descLower.includes('all')) {
|
|
290
|
+
estimate *= 2;
|
|
291
|
+
}
|
|
292
|
+
if (descLower.includes('pagination') || descLower.includes('pages')) {
|
|
293
|
+
estimate *= 3;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return Math.min(estimate, 100); // Cap at 100
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// =============================================================================
|
|
300
|
+
// Ghost-API Implementation
|
|
301
|
+
// =============================================================================
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Ghost-API class
|
|
305
|
+
*
|
|
306
|
+
* Provides a clean JSON interface for agents to interact with websites.
|
|
307
|
+
* Handles all DOM interaction transparently.
|
|
308
|
+
*
|
|
309
|
+
* Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6
|
|
310
|
+
*/
|
|
311
|
+
export class GhostApi {
|
|
312
|
+
private readonly normalizer: SemanticNormalizer;
|
|
313
|
+
// @ts-expect-error Reserved for future self-healing element location implementation
|
|
314
|
+
private readonly _triangulationEngine: TriangulationEngine;
|
|
315
|
+
private readonly logger: DeterministicLogger;
|
|
316
|
+
// @ts-expect-error Reserved for future browser session management implementation
|
|
317
|
+
private readonly _sessionOrchestrator?: ShadowSessionOrchestrator;
|
|
318
|
+
private readonly basePath: string;
|
|
319
|
+
private readonly maxPaginationPages: number;
|
|
320
|
+
private readonly executionTimeoutMs: number;
|
|
321
|
+
|
|
322
|
+
/** Registered goals and their schema mappings */
|
|
323
|
+
private goals: Map<string, { goal: GoalDefinition; mapping: SchemaMapping }> = new Map();
|
|
324
|
+
|
|
325
|
+
/** Active execution contexts */
|
|
326
|
+
private contexts: Map<string, ExecutionContext> = new Map();
|
|
327
|
+
|
|
328
|
+
constructor(config: GhostApiConfig) {
|
|
329
|
+
this.normalizer = config.normalizer;
|
|
330
|
+
this._triangulationEngine = config.triangulationEngine;
|
|
331
|
+
this.logger = config.logger;
|
|
332
|
+
this._sessionOrchestrator = config.sessionOrchestrator;
|
|
333
|
+
this.basePath = config.basePath ?? '/omni-bridge';
|
|
334
|
+
this.maxPaginationPages = config.maxPaginationPages ?? 10;
|
|
335
|
+
this.executionTimeoutMs = config.executionTimeoutMs ?? 30000;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ===========================================================================
|
|
339
|
+
// Goal Definition and Schema Mapping (Requirements 3.1, 3.2)
|
|
340
|
+
// ===========================================================================
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Define a goal and generate a schema mapping.
|
|
344
|
+
*
|
|
345
|
+
* Requirements: 3.1, 3.2
|
|
346
|
+
*/
|
|
347
|
+
async defineGoal(goal: GoalDefinition, html?: string): Promise<SchemaMapping> {
|
|
348
|
+
// If HTML is provided, use it to infer schema
|
|
349
|
+
let document: IntentDocument | undefined;
|
|
350
|
+
if (html) {
|
|
351
|
+
document = this.normalizer.normalize(html, goal.targetUrl);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Generate endpoint path
|
|
355
|
+
const endpoint = generateEndpointPath(goal, this.basePath);
|
|
356
|
+
|
|
357
|
+
// Infer or use provided schema
|
|
358
|
+
let schema: Record<string, unknown>;
|
|
359
|
+
if (goal.outputSchema) {
|
|
360
|
+
schema = goal.outputSchema;
|
|
361
|
+
} else if (document) {
|
|
362
|
+
schema = inferSchemaFromDocument(document, goal);
|
|
363
|
+
} else {
|
|
364
|
+
// Default schema for unknown structure
|
|
365
|
+
schema = {
|
|
366
|
+
type: 'object',
|
|
367
|
+
properties: {
|
|
368
|
+
data: { type: 'array', items: { type: 'object' } },
|
|
369
|
+
},
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Determine if auth is required (heuristic based on URL and goal)
|
|
374
|
+
const requiresAuth = this.detectAuthRequirement(goal);
|
|
375
|
+
|
|
376
|
+
// Estimate actions
|
|
377
|
+
const estimatedActions = document
|
|
378
|
+
? estimateActions(document, goal)
|
|
379
|
+
: 10; // Default estimate
|
|
380
|
+
|
|
381
|
+
const mapping: SchemaMapping = {
|
|
382
|
+
endpoint,
|
|
383
|
+
schema,
|
|
384
|
+
requiredAuth: requiresAuth,
|
|
385
|
+
estimatedActions,
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
// Store the goal and mapping
|
|
389
|
+
this.goals.set(endpoint, { goal, mapping });
|
|
390
|
+
|
|
391
|
+
return mapping;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Detect if authentication is likely required for a goal.
|
|
396
|
+
*/
|
|
397
|
+
private detectAuthRequirement(goal: GoalDefinition): boolean {
|
|
398
|
+
const urlLower = goal.targetUrl.toLowerCase();
|
|
399
|
+
const descLower = goal.description.toLowerCase();
|
|
400
|
+
const nameLower = goal.name.toLowerCase();
|
|
401
|
+
|
|
402
|
+
const authKeywords = [
|
|
403
|
+
'login', 'signin', 'sign-in', 'auth', 'account', 'dashboard',
|
|
404
|
+
'portal', 'admin', 'private', 'secure', 'member', 'profile',
|
|
405
|
+
'invoice', 'billing', 'payment', 'order', 'subscription',
|
|
406
|
+
];
|
|
407
|
+
|
|
408
|
+
return authKeywords.some(keyword =>
|
|
409
|
+
urlLower.includes(keyword) ||
|
|
410
|
+
descLower.includes(keyword) ||
|
|
411
|
+
nameLower.includes(keyword)
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Get the schema for a target URL.
|
|
417
|
+
*
|
|
418
|
+
* Requirements: 3.2
|
|
419
|
+
*/
|
|
420
|
+
async getSchema(targetUrl: string, html?: string): Promise<Record<string, unknown>> {
|
|
421
|
+
// Create a temporary goal for schema inference
|
|
422
|
+
const tempGoal: GoalDefinition = {
|
|
423
|
+
name: 'Schema_Discovery',
|
|
424
|
+
targetUrl,
|
|
425
|
+
description: 'Discover schema for target URL',
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
if (html) {
|
|
429
|
+
const document = this.normalizer.normalize(html, targetUrl);
|
|
430
|
+
return inferSchemaFromDocument(document, tempGoal);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Return default schema if no HTML provided
|
|
434
|
+
return {
|
|
435
|
+
type: 'object',
|
|
436
|
+
properties: {
|
|
437
|
+
data: { type: 'array', items: { type: 'object' } },
|
|
438
|
+
},
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Get a registered goal by endpoint.
|
|
444
|
+
*/
|
|
445
|
+
getGoal(endpoint: string): { goal: GoalDefinition; mapping: SchemaMapping } | undefined {
|
|
446
|
+
return this.goals.get(endpoint);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* List all registered endpoints.
|
|
451
|
+
*/
|
|
452
|
+
listEndpoints(): string[] {
|
|
453
|
+
return Array.from(this.goals.keys());
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
// ===========================================================================
|
|
458
|
+
// Transparent DOM Interaction (Requirement 3.3)
|
|
459
|
+
// ===========================================================================
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Execute a goal and return structured JSON.
|
|
463
|
+
* Handles all DOM interaction transparently - agent never sees HTML.
|
|
464
|
+
*
|
|
465
|
+
* Requirements: 3.3, 3.4, 3.5, 3.6
|
|
466
|
+
*/
|
|
467
|
+
async execute(
|
|
468
|
+
endpoint: string,
|
|
469
|
+
params?: Record<string, unknown>,
|
|
470
|
+
html?: string
|
|
471
|
+
): Promise<GhostResponse> {
|
|
472
|
+
const startTime = performance.now();
|
|
473
|
+
const registered = this.goals.get(endpoint);
|
|
474
|
+
|
|
475
|
+
if (!registered) {
|
|
476
|
+
throw new Error(`Endpoint not found: ${endpoint}`);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const { goal, mapping } = registered;
|
|
480
|
+
|
|
481
|
+
// Create execution context
|
|
482
|
+
const sessionId = `ghost_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
|
483
|
+
const context: ExecutionContext = {
|
|
484
|
+
sessionId,
|
|
485
|
+
goal,
|
|
486
|
+
schemaMapping: mapping,
|
|
487
|
+
startTime,
|
|
488
|
+
actionsPerformed: 0,
|
|
489
|
+
triangulationHeals: 0,
|
|
490
|
+
extractedData: [],
|
|
491
|
+
currentPage: 1,
|
|
492
|
+
};
|
|
493
|
+
this.contexts.set(sessionId, context);
|
|
494
|
+
|
|
495
|
+
try {
|
|
496
|
+
// If HTML is provided, process it directly
|
|
497
|
+
if (html) {
|
|
498
|
+
return await this.processHtml(context, html, params);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Otherwise, return a placeholder response (real implementation would use browser)
|
|
502
|
+
return this.createResponse(context, []);
|
|
503
|
+
} finally {
|
|
504
|
+
this.contexts.delete(sessionId);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Process HTML and extract data according to the goal.
|
|
510
|
+
*/
|
|
511
|
+
private async processHtml(
|
|
512
|
+
context: ExecutionContext,
|
|
513
|
+
html: string,
|
|
514
|
+
params?: Record<string, unknown>
|
|
515
|
+
): Promise<GhostResponse> {
|
|
516
|
+
// Normalize the HTML to Intent Document
|
|
517
|
+
const document = this.normalizer.normalize(html, context.goal.targetUrl);
|
|
518
|
+
|
|
519
|
+
// Log the navigation action
|
|
520
|
+
this.logAction(context, 'navigate', 'NAV_ID:PAGE_LOAD', 'success');
|
|
521
|
+
|
|
522
|
+
// Handle form filling if params provided
|
|
523
|
+
if (params && Object.keys(params).length > 0) {
|
|
524
|
+
await this.handleFormFilling(context, document, params);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Extract data from the document
|
|
528
|
+
const extractedData = this.extractData(context, document);
|
|
529
|
+
context.extractedData.push(...extractedData);
|
|
530
|
+
|
|
531
|
+
// Check for pagination
|
|
532
|
+
const paginationInfo = this.detectPagination(document);
|
|
533
|
+
if (paginationInfo.hasPagination && context.currentPage < this.maxPaginationPages) {
|
|
534
|
+
// In a real implementation, we would navigate to next page
|
|
535
|
+
// For now, we just note that pagination exists
|
|
536
|
+
this.logAction(context, 'extract', 'NAV_ID:PAGINATION_DETECTED', 'success');
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
return this.createResponse(context, context.extractedData);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Handle form filling with provided parameters.
|
|
544
|
+
*/
|
|
545
|
+
private async handleFormFilling(
|
|
546
|
+
context: ExecutionContext,
|
|
547
|
+
document: IntentDocument,
|
|
548
|
+
params: Record<string, unknown>
|
|
549
|
+
): Promise<void> {
|
|
550
|
+
for (const form of document.forms) {
|
|
551
|
+
for (const field of form.fields) {
|
|
552
|
+
const fieldName = sanitizeFieldName(field.name || field.label);
|
|
553
|
+
if (fieldName && params[fieldName] !== undefined) {
|
|
554
|
+
// Log the typing action
|
|
555
|
+
this.logAction(
|
|
556
|
+
context,
|
|
557
|
+
'type',
|
|
558
|
+
field.intentId,
|
|
559
|
+
'success',
|
|
560
|
+
String(params[fieldName])
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Log form submission if we filled any fields
|
|
566
|
+
if (form.submitButtonId) {
|
|
567
|
+
this.logAction(context, 'click', form.submitButtonId, 'success');
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Extract data from an Intent Document.
|
|
574
|
+
*/
|
|
575
|
+
private extractData(
|
|
576
|
+
context: ExecutionContext,
|
|
577
|
+
document: IntentDocument
|
|
578
|
+
): Record<string, unknown>[] {
|
|
579
|
+
const results: Record<string, unknown>[] = [];
|
|
580
|
+
const dataItem: Record<string, unknown> = {};
|
|
581
|
+
|
|
582
|
+
// Extract from display elements
|
|
583
|
+
for (const element of document.elements) {
|
|
584
|
+
if (element.role === 'display' && element.label) {
|
|
585
|
+
const fieldName = sanitizeFieldName(element.label);
|
|
586
|
+
if (fieldName) {
|
|
587
|
+
dataItem[fieldName] = element.label;
|
|
588
|
+
this.logAction(context, 'extract', element.intentId, 'success');
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Extract from forms (read-only data)
|
|
594
|
+
for (const form of document.forms) {
|
|
595
|
+
for (const field of form.fields) {
|
|
596
|
+
const fieldName = sanitizeFieldName(field.name || field.label);
|
|
597
|
+
if (fieldName && field.placeholder) {
|
|
598
|
+
dataItem[fieldName] = field.placeholder;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (Object.keys(dataItem).length > 0) {
|
|
604
|
+
results.push(dataItem);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
return results;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Log an action to the deterministic logger.
|
|
612
|
+
*/
|
|
613
|
+
private logAction(
|
|
614
|
+
context: ExecutionContext,
|
|
615
|
+
action: ActionLogEntry['action'],
|
|
616
|
+
intentId: string,
|
|
617
|
+
result: ActionLogEntry['result'],
|
|
618
|
+
value?: string
|
|
619
|
+
): void {
|
|
620
|
+
context.actionsPerformed++;
|
|
621
|
+
|
|
622
|
+
this.logger.logAction({
|
|
623
|
+
sessionId: context.sessionId,
|
|
624
|
+
action,
|
|
625
|
+
intentId,
|
|
626
|
+
result,
|
|
627
|
+
value,
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// ===========================================================================
|
|
632
|
+
// Transparent DOM Interaction Helpers (Requirement 3.3)
|
|
633
|
+
// ===========================================================================
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Simulate clicking an element.
|
|
637
|
+
* Agent never sees the actual DOM - just the result.
|
|
638
|
+
*
|
|
639
|
+
* Requirement: 3.3
|
|
640
|
+
*/
|
|
641
|
+
async click(
|
|
642
|
+
context: ExecutionContext,
|
|
643
|
+
element: IntentElement
|
|
644
|
+
): Promise<ActionResult> {
|
|
645
|
+
try {
|
|
646
|
+
// Log the click action
|
|
647
|
+
this.logAction(context, 'click', element.intentId, 'success');
|
|
648
|
+
|
|
649
|
+
return {
|
|
650
|
+
success: true,
|
|
651
|
+
};
|
|
652
|
+
} catch (error) {
|
|
653
|
+
this.logAction(context, 'click', element.intentId, 'failure');
|
|
654
|
+
return {
|
|
655
|
+
success: false,
|
|
656
|
+
error: error instanceof Error ? error.message : 'Click failed',
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Simulate typing into an element.
|
|
663
|
+
* Agent never sees the actual DOM - just the result.
|
|
664
|
+
*
|
|
665
|
+
* Requirement: 3.3
|
|
666
|
+
*/
|
|
667
|
+
async type(
|
|
668
|
+
context: ExecutionContext,
|
|
669
|
+
element: IntentElement,
|
|
670
|
+
value: string
|
|
671
|
+
): Promise<ActionResult> {
|
|
672
|
+
try {
|
|
673
|
+
// Log the type action
|
|
674
|
+
this.logAction(context, 'type', element.intentId, 'success', value);
|
|
675
|
+
|
|
676
|
+
return {
|
|
677
|
+
success: true,
|
|
678
|
+
};
|
|
679
|
+
} catch (error) {
|
|
680
|
+
this.logAction(context, 'type', element.intentId, 'failure', value);
|
|
681
|
+
return {
|
|
682
|
+
success: false,
|
|
683
|
+
error: error instanceof Error ? error.message : 'Type failed',
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Simulate navigation to a URL.
|
|
690
|
+
* Agent never sees the actual DOM - just the result.
|
|
691
|
+
*
|
|
692
|
+
* Requirement: 3.3
|
|
693
|
+
*/
|
|
694
|
+
async navigate(
|
|
695
|
+
context: ExecutionContext,
|
|
696
|
+
url: string
|
|
697
|
+
): Promise<ActionResult> {
|
|
698
|
+
try {
|
|
699
|
+
// Log the navigation action
|
|
700
|
+
this.logAction(context, 'navigate', `NAV_ID:${url}`, 'success', url);
|
|
701
|
+
|
|
702
|
+
return {
|
|
703
|
+
success: true,
|
|
704
|
+
};
|
|
705
|
+
} catch (error) {
|
|
706
|
+
this.logAction(context, 'navigate', `NAV_ID:${url}`, 'failure', url);
|
|
707
|
+
return {
|
|
708
|
+
success: false,
|
|
709
|
+
error: error instanceof Error ? error.message : 'Navigation failed',
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Wait for a condition or timeout.
|
|
716
|
+
*
|
|
717
|
+
* Requirement: 3.3
|
|
718
|
+
*/
|
|
719
|
+
async wait(
|
|
720
|
+
context: ExecutionContext,
|
|
721
|
+
condition: string,
|
|
722
|
+
timeoutMs: number = 5000
|
|
723
|
+
): Promise<ActionResult> {
|
|
724
|
+
try {
|
|
725
|
+
// Log the wait action
|
|
726
|
+
this.logAction(context, 'wait', `WAIT:${condition}`, 'success', String(timeoutMs));
|
|
727
|
+
|
|
728
|
+
return {
|
|
729
|
+
success: true,
|
|
730
|
+
};
|
|
731
|
+
} catch (error) {
|
|
732
|
+
this.logAction(context, 'wait', `WAIT:${condition}`, 'failure');
|
|
733
|
+
return {
|
|
734
|
+
success: false,
|
|
735
|
+
error: error instanceof Error ? error.message : 'Wait failed',
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Extract data from an element.
|
|
742
|
+
* Agent receives clean JSON, never raw HTML.
|
|
743
|
+
*
|
|
744
|
+
* Requirement: 3.3
|
|
745
|
+
*/
|
|
746
|
+
async extract(
|
|
747
|
+
context: ExecutionContext,
|
|
748
|
+
element: IntentElement
|
|
749
|
+
): Promise<ActionResult> {
|
|
750
|
+
try {
|
|
751
|
+
// Log the extract action
|
|
752
|
+
this.logAction(context, 'extract', element.intentId, 'success');
|
|
753
|
+
|
|
754
|
+
return {
|
|
755
|
+
success: true,
|
|
756
|
+
data: {
|
|
757
|
+
intentId: element.intentId,
|
|
758
|
+
label: element.label,
|
|
759
|
+
role: element.role,
|
|
760
|
+
},
|
|
761
|
+
};
|
|
762
|
+
} catch (error) {
|
|
763
|
+
this.logAction(context, 'extract', element.intentId, 'failure');
|
|
764
|
+
return {
|
|
765
|
+
success: false,
|
|
766
|
+
error: error instanceof Error ? error.message : 'Extract failed',
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Execute a sequence of DOM actions transparently.
|
|
773
|
+
* Agent provides high-level intent, OmniBridge handles the details.
|
|
774
|
+
*
|
|
775
|
+
* Requirement: 3.3
|
|
776
|
+
*/
|
|
777
|
+
async executeActions(
|
|
778
|
+
context: ExecutionContext,
|
|
779
|
+
document: IntentDocument,
|
|
780
|
+
actions: Array<{
|
|
781
|
+
type: 'click' | 'type' | 'navigate' | 'wait' | 'extract';
|
|
782
|
+
target?: string; // Intent ID or URL
|
|
783
|
+
value?: string;
|
|
784
|
+
}>
|
|
785
|
+
): Promise<ActionResult[]> {
|
|
786
|
+
const results: ActionResult[] = [];
|
|
787
|
+
|
|
788
|
+
for (const action of actions) {
|
|
789
|
+
let result: ActionResult;
|
|
790
|
+
|
|
791
|
+
switch (action.type) {
|
|
792
|
+
case 'click': {
|
|
793
|
+
const element = this.findElementByIntentId(document, action.target || '');
|
|
794
|
+
if (element) {
|
|
795
|
+
result = await this.click(context, element);
|
|
796
|
+
} else {
|
|
797
|
+
result = { success: false, error: `Element not found: ${action.target}` };
|
|
798
|
+
}
|
|
799
|
+
break;
|
|
800
|
+
}
|
|
801
|
+
case 'type': {
|
|
802
|
+
const element = this.findElementByIntentId(document, action.target || '');
|
|
803
|
+
if (element) {
|
|
804
|
+
result = await this.type(context, element, action.value || '');
|
|
805
|
+
} else {
|
|
806
|
+
result = { success: false, error: `Element not found: ${action.target}` };
|
|
807
|
+
}
|
|
808
|
+
break;
|
|
809
|
+
}
|
|
810
|
+
case 'navigate':
|
|
811
|
+
result = await this.navigate(context, action.target || '');
|
|
812
|
+
break;
|
|
813
|
+
case 'wait':
|
|
814
|
+
result = await this.wait(context, action.target || 'page_load');
|
|
815
|
+
break;
|
|
816
|
+
case 'extract': {
|
|
817
|
+
const element = this.findElementByIntentId(document, action.target || '');
|
|
818
|
+
if (element) {
|
|
819
|
+
result = await this.extract(context, element);
|
|
820
|
+
} else {
|
|
821
|
+
result = { success: false, error: `Element not found: ${action.target}` };
|
|
822
|
+
}
|
|
823
|
+
break;
|
|
824
|
+
}
|
|
825
|
+
default:
|
|
826
|
+
result = { success: false, error: `Unknown action type: ${action.type}` };
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
results.push(result);
|
|
830
|
+
|
|
831
|
+
// Stop on failure
|
|
832
|
+
if (!result.success) {
|
|
833
|
+
break;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
return results;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* Find an element by its Intent ID.
|
|
842
|
+
*/
|
|
843
|
+
private findElementByIntentId(
|
|
844
|
+
document: IntentDocument,
|
|
845
|
+
intentId: string
|
|
846
|
+
): IntentElement | undefined {
|
|
847
|
+
return document.elements.find(el => el.intentId === intentId);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// ===========================================================================
|
|
851
|
+
// Pagination Handling (Requirement 3.5)
|
|
852
|
+
// ===========================================================================
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* Detect pagination in an Intent Document.
|
|
856
|
+
*
|
|
857
|
+
* Requirement: 3.5
|
|
858
|
+
*/
|
|
859
|
+
detectPagination(document: IntentDocument): PaginationInfo {
|
|
860
|
+
const paginationKeywords = [
|
|
861
|
+
'next', 'previous', 'prev', 'page', 'pagination',
|
|
862
|
+
'more', 'load more', 'show more', 'view more',
|
|
863
|
+
'»', '›', '‹', '«', '→', '←',
|
|
864
|
+
];
|
|
865
|
+
|
|
866
|
+
const nextPageIndicators = [
|
|
867
|
+
'next', '»', '›', '→', 'next page', 'next →', '→ next',
|
|
868
|
+
'load more', 'show more', 'view more', 'see more',
|
|
869
|
+
];
|
|
870
|
+
|
|
871
|
+
let nextPageElement: IntentElement | undefined;
|
|
872
|
+
let hasPagination = false;
|
|
873
|
+
let totalPages: number | undefined;
|
|
874
|
+
|
|
875
|
+
// Look for pagination elements
|
|
876
|
+
for (const element of document.elements) {
|
|
877
|
+
const labelLower = element.label.toLowerCase();
|
|
878
|
+
const intentLower = element.intentId.toLowerCase();
|
|
879
|
+
|
|
880
|
+
// Check for next page indicators
|
|
881
|
+
if (nextPageIndicators.some(indicator =>
|
|
882
|
+
labelLower.includes(indicator) || labelLower === indicator
|
|
883
|
+
)) {
|
|
884
|
+
nextPageElement = element;
|
|
885
|
+
hasPagination = true;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// Check for pagination container
|
|
889
|
+
if (paginationKeywords.some(kw => labelLower.includes(kw) || intentLower.includes(kw))) {
|
|
890
|
+
hasPagination = true;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// Try to detect total pages from labels like "Page 1 of 10"
|
|
894
|
+
const pageMatch = labelLower.match(/page\s*\d+\s*of\s*(\d+)/);
|
|
895
|
+
if (pageMatch) {
|
|
896
|
+
totalPages = parseInt(pageMatch[1], 10);
|
|
897
|
+
hasPagination = true;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// Check for numbered page links
|
|
901
|
+
const pageNumberMatch = labelLower.match(/^(\d+)$/);
|
|
902
|
+
if (pageNumberMatch && element.role === 'navigation') {
|
|
903
|
+
hasPagination = true;
|
|
904
|
+
const pageNum = parseInt(pageNumberMatch[1], 10);
|
|
905
|
+
if (!totalPages || pageNum > totalPages) {
|
|
906
|
+
totalPages = pageNum;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// Check navigation links for pagination
|
|
912
|
+
for (const link of document.navigation.primaryLinks) {
|
|
913
|
+
const labelLower = link.label.toLowerCase();
|
|
914
|
+
if (nextPageIndicators.some(indicator => labelLower.includes(indicator))) {
|
|
915
|
+
hasPagination = true;
|
|
916
|
+
}
|
|
917
|
+
if (labelLower.includes('page') || /^\d+$/.test(labelLower)) {
|
|
918
|
+
hasPagination = true;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
return {
|
|
923
|
+
hasPagination,
|
|
924
|
+
nextPageElement,
|
|
925
|
+
totalPages,
|
|
926
|
+
currentPage: 1,
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* Handle pagination automatically.
|
|
932
|
+
* Collects data from multiple pages.
|
|
933
|
+
*
|
|
934
|
+
* Requirement: 3.5
|
|
935
|
+
*/
|
|
936
|
+
async handlePagination(
|
|
937
|
+
context: ExecutionContext,
|
|
938
|
+
document: IntentDocument,
|
|
939
|
+
paginationInfo: PaginationInfo
|
|
940
|
+
): Promise<Record<string, unknown>[]> {
|
|
941
|
+
const allData: Record<string, unknown>[] = [];
|
|
942
|
+
|
|
943
|
+
// Extract data from current page
|
|
944
|
+
const currentPageData = this.extractData(context, document);
|
|
945
|
+
allData.push(...currentPageData);
|
|
946
|
+
|
|
947
|
+
// In a real implementation, we would:
|
|
948
|
+
// 1. Click the next page element
|
|
949
|
+
// 2. Wait for page load
|
|
950
|
+
// 3. Normalize the new HTML
|
|
951
|
+
// 4. Extract data
|
|
952
|
+
// 5. Repeat until no more pages or max reached
|
|
953
|
+
|
|
954
|
+
// For now, we just log that pagination was detected
|
|
955
|
+
if (paginationInfo.hasPagination && paginationInfo.nextPageElement) {
|
|
956
|
+
this.logAction(
|
|
957
|
+
context,
|
|
958
|
+
'click',
|
|
959
|
+
paginationInfo.nextPageElement.intentId,
|
|
960
|
+
'success'
|
|
961
|
+
);
|
|
962
|
+
context.currentPage++;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
return allData;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// ===========================================================================
|
|
969
|
+
// Response Formatting (Requirements 3.4, 3.6)
|
|
970
|
+
// ===========================================================================
|
|
971
|
+
|
|
972
|
+
/**
|
|
973
|
+
* Create a Ghost-API response with proper formatting.
|
|
974
|
+
*
|
|
975
|
+
* Requirements: 3.4, 3.6
|
|
976
|
+
*/
|
|
977
|
+
createResponse(
|
|
978
|
+
context: ExecutionContext,
|
|
979
|
+
data: Record<string, unknown>[]
|
|
980
|
+
): GhostResponse {
|
|
981
|
+
const executionTimeMs = performance.now() - context.startTime;
|
|
982
|
+
|
|
983
|
+
// Calculate confidence based on actions and heals
|
|
984
|
+
const confidence = this.calculateConfidence(context);
|
|
985
|
+
|
|
986
|
+
// Generate verification hash
|
|
987
|
+
const verificationHash = this.generateVerificationHash(context);
|
|
988
|
+
|
|
989
|
+
return {
|
|
990
|
+
data: data.length === 1 ? data[0] : data,
|
|
991
|
+
metadata: {
|
|
992
|
+
confidence,
|
|
993
|
+
executionTimeMs: Math.round(executionTimeMs * 100) / 100,
|
|
994
|
+
actionsPerformed: context.actionsPerformed,
|
|
995
|
+
triangulationHeals: context.triangulationHeals,
|
|
996
|
+
},
|
|
997
|
+
verificationHash,
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* Calculate confidence score for the execution.
|
|
1003
|
+
*/
|
|
1004
|
+
private calculateConfidence(context: ExecutionContext): number {
|
|
1005
|
+
let confidence = 1.0;
|
|
1006
|
+
|
|
1007
|
+
// Reduce confidence for each triangulation heal
|
|
1008
|
+
confidence -= context.triangulationHeals * 0.05;
|
|
1009
|
+
|
|
1010
|
+
// Reduce confidence if no data was extracted
|
|
1011
|
+
if (context.extractedData.length === 0) {
|
|
1012
|
+
confidence -= 0.2;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// Reduce confidence if very few actions were performed
|
|
1016
|
+
if (context.actionsPerformed < 2) {
|
|
1017
|
+
confidence -= 0.1;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// Ensure confidence is between 0 and 1
|
|
1021
|
+
return Math.max(0, Math.min(1, confidence));
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
/**
|
|
1025
|
+
* Generate a verification hash for the execution.
|
|
1026
|
+
*/
|
|
1027
|
+
private generateVerificationHash(context: ExecutionContext): string {
|
|
1028
|
+
const log = this.logger.getLog(context.sessionId);
|
|
1029
|
+
|
|
1030
|
+
if (log.length === 0) {
|
|
1031
|
+
// Generate hash from context if no log
|
|
1032
|
+
const data = JSON.stringify({
|
|
1033
|
+
sessionId: context.sessionId,
|
|
1034
|
+
goal: context.goal.name,
|
|
1035
|
+
actionsPerformed: context.actionsPerformed,
|
|
1036
|
+
timestamp: Date.now(),
|
|
1037
|
+
});
|
|
1038
|
+
return createHash('sha256').update(data).digest('hex');
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
return this.logger.hashActionSequence(log);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// ===========================================================================
|
|
1045
|
+
// Utility Methods
|
|
1046
|
+
// ===========================================================================
|
|
1047
|
+
|
|
1048
|
+
/**
|
|
1049
|
+
* Clear all registered goals.
|
|
1050
|
+
*/
|
|
1051
|
+
clearGoals(): void {
|
|
1052
|
+
this.goals.clear();
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/**
|
|
1056
|
+
* Get the number of registered goals.
|
|
1057
|
+
*/
|
|
1058
|
+
getGoalCount(): number {
|
|
1059
|
+
return this.goals.size;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/**
|
|
1063
|
+
* Get configuration values.
|
|
1064
|
+
*/
|
|
1065
|
+
getConfig(): {
|
|
1066
|
+
basePath: string;
|
|
1067
|
+
maxPaginationPages: number;
|
|
1068
|
+
executionTimeoutMs: number;
|
|
1069
|
+
} {
|
|
1070
|
+
return {
|
|
1071
|
+
basePath: this.basePath,
|
|
1072
|
+
maxPaginationPages: this.maxPaginationPages,
|
|
1073
|
+
executionTimeoutMs: this.executionTimeoutMs,
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
// =============================================================================
|
|
1079
|
+
// Factory Function
|
|
1080
|
+
// =============================================================================
|
|
1081
|
+
|
|
1082
|
+
/**
|
|
1083
|
+
* Create a new Ghost-API instance.
|
|
1084
|
+
*/
|
|
1085
|
+
export function createGhostApi(config: GhostApiConfig): GhostApi {
|
|
1086
|
+
return new GhostApi(config);
|
|
1087
|
+
}
|